Compare commits
65 Commits
588b238b49
...
a9216c4f4b
| Author | SHA1 | Date | |
|---|---|---|---|
| a9216c4f4b | |||
| af4f713395 | |||
| 472e71d1a2 | |||
| 89597dbc87 | |||
| 599c5dd239 | |||
| 7308d9bbcd | |||
| b4ab66b4a6 | |||
| 3634d2eb79 | |||
| c4ebbd903d | |||
| e239b28678 | |||
| dc07ab72d2 | |||
| d4d61bfed2 | |||
| 1480eefbe6 | |||
| 73f11ea1a0 | |||
| f6344c19f9 | |||
| 5ee897cfad | |||
| 3ad0f0a63d | |||
| 71ba71476a | |||
| 5a2c36e276 | |||
| beb0e32dde | |||
| 3607e4491b | |||
| b5946b9da3 | |||
| 7c3f2192ef | |||
| bebab7ba1e | |||
| 78e1e2e0ef | |||
| dc9b7f9d12 | |||
| bf76f729aa | |||
| 28af7994f8 | |||
| 23d100cdde | |||
| 97a65ae01e | |||
| df8dae047f | |||
| 1f0046a8ee | |||
| d33bacf628 | |||
| 814a6a54e8 | |||
| 5b1d3903e0 | |||
| 416b18794c | |||
| 5059e6db14 | |||
| 967339a400 | |||
| 60a29816fb | |||
| a8e4ef76e9 | |||
| a006aa5c85 | |||
| 981780536e | |||
| 700080f001 | |||
| ea73323fce | |||
| 558cf11abc | |||
| a3f31e92c1 | |||
| eee5771957 | |||
| fe3f235fcd | |||
| 997043fac9 | |||
| 8c2971f217 | |||
| 38c5c16824 | |||
| 0cc2e4ce4e | |||
| 5ddfed8d28 | |||
| 177d10de15 | |||
| 6df388fb98 | |||
| 06a0daedfe | |||
| e5d1ba9b8d | |||
| 51ac1ad81d | |||
| fe3f254dfc | |||
| 909a2f42bc | |||
| bfbdf0fc19 | |||
| 78111f161f | |||
| da6754e6f5 | |||
| 5206fb2e1a | |||
| 24b5a0d7ee |
479
docs/course-management-api-integration.md
Normal file
479
docs/course-management-api-integration.md
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
# Course Management API Integration Guide
|
||||
|
||||
This document describes the Course Management related APIs used by the admin frontend (`Yimaru-Admin`) and how to integrate them safely.
|
||||
|
||||
It is based on:
|
||||
- `src/api/courses.api.ts`
|
||||
- `src/api/files.api.ts`
|
||||
- `src/types/course.types.ts`
|
||||
- `src/api/http.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1) Base setup and auth behavior
|
||||
|
||||
### Base URL
|
||||
- All requests use `VITE_API_BASE_URL` from environment.
|
||||
|
||||
### Authentication
|
||||
- Access token is sent automatically as `Authorization: Bearer <access_token>`.
|
||||
- On `401`, the frontend attempts token refresh via:
|
||||
- `POST /auth/refresh`
|
||||
- payload: `{ access_token, refresh_token, role, member_id }`
|
||||
- If refresh fails, auth data is cleared and user is redirected to `/login`.
|
||||
|
||||
### Transport notes
|
||||
- Axios automatically handles `multipart/form-data` boundaries for file upload.
|
||||
- Any network failure without response also redirects to `/login` (current client behavior).
|
||||
|
||||
---
|
||||
|
||||
## 2) Core domain model used by frontend
|
||||
|
||||
Current hierarchy used by content management:
|
||||
- `Category`
|
||||
- `Sub-category`
|
||||
- `Course`
|
||||
- `Level (CEFR)`
|
||||
- `Module`
|
||||
- `Sub-module`
|
||||
- `Videos`
|
||||
- `Lessons` (question sets with `set_type = QUIZ`)
|
||||
- `Practices` (question sets with `set_type = PRACTICE`)
|
||||
|
||||
Important migration note:
|
||||
- Some APIs/types are marked as legacy (`Program`, old `Level/Module` flows).
|
||||
- Current frontend mostly uses unified hierarchy endpoints under `/course-management/...` plus `/question-sets` and `/questions`.
|
||||
|
||||
---
|
||||
|
||||
## 3) File/media APIs (used by course management)
|
||||
|
||||
## 3.1 Upload media
|
||||
|
||||
### Endpoint
|
||||
- `POST /files/upload`
|
||||
|
||||
### Supports
|
||||
- `media_type`: `"image" | "audio" | "video"`
|
||||
- File upload via multipart (`file`) or URL import via JSON (`source_url`).
|
||||
|
||||
### For video uploads
|
||||
- Can send optional `title` and `description`.
|
||||
|
||||
### Typical response fields used by frontend
|
||||
- `data.object_key`
|
||||
- `data.url`
|
||||
- `data.provider` (`MINIO` or `VIMEO`)
|
||||
- `data.vimeo_id`
|
||||
- `data.embed_url`
|
||||
|
||||
### Frontend wrapper functions
|
||||
- `uploadAudioFile(fileOrUrl)`
|
||||
- `uploadImageFile(fileOrUrl)`
|
||||
- `uploadVideoFile(fileOrUrl, { title?, description? })`
|
||||
|
||||
## 3.2 Resolve object key to URL
|
||||
|
||||
### Endpoint
|
||||
- `GET /files/url?key=<object_key>`
|
||||
|
||||
### Use case
|
||||
- Resolve media object key when only key is stored.
|
||||
|
||||
---
|
||||
|
||||
## 4) Category and course APIs
|
||||
|
||||
## 4.1 Get categories (normalized in frontend)
|
||||
|
||||
### Endpoint called
|
||||
- `GET /course-management/hierarchy`
|
||||
|
||||
### Frontend behavior
|
||||
- Client transforms flat hierarchy rows into category list.
|
||||
- Duplicated category names are merged client-side by "richest" record.
|
||||
|
||||
### Wrapper
|
||||
- `getCourseCategories()`
|
||||
|
||||
## 4.2 Create category or sub-category
|
||||
|
||||
### Category
|
||||
- `POST /course-management/categories`
|
||||
- body: `{ name }`
|
||||
|
||||
### Sub-category
|
||||
- `POST /course-management/sub-categories`
|
||||
- body: `{ category_id, name }`
|
||||
|
||||
### Wrapper
|
||||
- `createCourseCategory({ name, parent_id? })`
|
||||
- if `parent_id` exists, creates sub-category; else category.
|
||||
|
||||
## 4.3 Delete category/sub-category
|
||||
- `DELETE /course-management/categories/:categoryId`
|
||||
- `DELETE /course-management/sub-categories/:subCategoryId`
|
||||
|
||||
Wrappers:
|
||||
- `deleteCourseCategory(categoryId)`
|
||||
- `deleteCourseSubCategory(subCategoryId)`
|
||||
|
||||
## 4.4 Courses by category
|
||||
|
||||
### Endpoint called
|
||||
- `GET /course-management/hierarchy`
|
||||
|
||||
### Frontend behavior
|
||||
- Filters and maps rows to courses client-side.
|
||||
- If duplicate category names exist, it includes rows matching requested category name.
|
||||
|
||||
Wrapper:
|
||||
- `getCoursesByCategory(categoryId)`
|
||||
|
||||
## 4.5 Course CRUD
|
||||
- `POST /course-management/courses`
|
||||
- `PUT /course-management/courses/:courseId`
|
||||
- `PUT /course-management/courses/:courseId` (status toggle via `is_active`)
|
||||
- `DELETE /course-management/courses/:courseId`
|
||||
- `POST /course-management/courses/:courseId/thumbnail`
|
||||
|
||||
Wrappers:
|
||||
- `createCourse(data)`
|
||||
- `updateCourse(courseId, data)`
|
||||
- `updateCourseStatus(courseId, isActive)`
|
||||
- `deleteCourse(courseId)`
|
||||
- `updateCourseThumbnail(courseId, thumbnailUrl)`
|
||||
|
||||
---
|
||||
|
||||
## 5) Course hierarchy (levels/modules/sub-modules)
|
||||
|
||||
## 5.1 Get full hierarchy for one course
|
||||
|
||||
### Endpoint
|
||||
- `GET /course-management/courses/:courseId/hierarchy`
|
||||
|
||||
### Wrapper
|
||||
- `getSubModulesByCourse(courseId)`
|
||||
|
||||
### Frontend behavior
|
||||
- Maps hierarchy rows into `sub_courses` shape (compatibility naming).
|
||||
- This is the primary source for module/sub-module tree rendering.
|
||||
|
||||
## 5.2 Create sub-module flow (composed)
|
||||
|
||||
`createSubModule(data)` is a multi-step client workflow:
|
||||
1. `POST /course-management/levels`
|
||||
2. `POST /course-management/modules`
|
||||
3. `POST /course-management/sub-modules`
|
||||
|
||||
Use this when creating a new sub-module from minimal info.
|
||||
|
||||
## 5.3 Direct level/module/sub-module creation
|
||||
- `createModuleInLevel(levelId, title, description, displayOrder?)`
|
||||
- `POST /course-management/modules`
|
||||
- `createSubModuleInModule(moduleId, title, description, displayOrder?)`
|
||||
- `POST /course-management/sub-modules`
|
||||
|
||||
## 5.4 Update/delete sub-module
|
||||
- `PUT /course-management/sub-modules/:subModuleId`
|
||||
- `PUT /course-management/sub-modules/:subModuleId` (status payload)
|
||||
- `DELETE /course-management/sub-modules/:subModuleId`
|
||||
- `POST /course-management/sub-courses/:subModuleId/thumbnail` (compat endpoint)
|
||||
|
||||
Wrappers:
|
||||
- `updateSubModule(...)`
|
||||
- `updateSubModuleStatus(...)`
|
||||
- `deleteSubModule(...)`
|
||||
- `updateSubModuleThumbnail(...)`
|
||||
|
||||
---
|
||||
|
||||
## 6) Video APIs (sub-module videos)
|
||||
|
||||
## 6.1 List videos for sub-module
|
||||
- `GET /course-management/sub-modules/:subModuleId/videos`
|
||||
- wrapper: `getVideosBySubModule(subModuleId)`
|
||||
|
||||
## 6.2 Create video
|
||||
|
||||
Two wrapper variants, same endpoint:
|
||||
- `POST /course-management/sub-module-videos`
|
||||
|
||||
### Minimal variant
|
||||
- `createSubCourseVideo({ sub_module_id|sub_course_id, title, description, video_url })`
|
||||
|
||||
### Extended variant
|
||||
- `createCourseVideo({ sub_module_id|sub_course_id, title, description, video_url, duration, resolution?, visibility?, display_order?, status? })`
|
||||
|
||||
## 6.3 Update/delete video
|
||||
- `PUT /course-management/sub-module-videos/:videoId`
|
||||
- `DELETE /course-management/sub-module-videos/:videoId`
|
||||
|
||||
Wrappers:
|
||||
- `updateSubCourseVideo(videoId, data)`
|
||||
- `deleteSubCourseVideo(videoId)`
|
||||
|
||||
---
|
||||
|
||||
## 7) Practices and lessons
|
||||
|
||||
## 7.1 Practices by sub-module
|
||||
- `GET /question-sets/by-owner?owner_type=SUB_MODULE&owner_id=:subModuleId`
|
||||
- wrapper: `getPracticesBySubModule(subModuleId)`
|
||||
|
||||
## 7.2 Create practice (composed)
|
||||
|
||||
`createPractice(data)` does:
|
||||
1. `POST /question-sets`
|
||||
- `set_type: "PRACTICE"`
|
||||
- `owner_type: "SUB_MODULE"`
|
||||
- `owner_id: sub_module_id`
|
||||
2. If step 1 succeeds, links to sub-module practice:
|
||||
- `POST /course-management/sub-module-practices`
|
||||
- includes `question_set_id` and intro metadata
|
||||
|
||||
## 7.3 Create lesson (composed)
|
||||
|
||||
`createLesson(data)` does:
|
||||
1. `POST /question-sets`
|
||||
- `set_type: "QUIZ"`
|
||||
- `owner_type: "SUB_MODULE"`
|
||||
2. Link question set as lesson:
|
||||
- `POST /course-management/sub-module-lessons`
|
||||
|
||||
## 7.4 Practice update/delete/status
|
||||
- `PUT /course-management/practices/:practiceId`
|
||||
- `PUT /course-management/practices/:practiceId` (status)
|
||||
- `DELETE /course-management/practices/:practiceId`
|
||||
|
||||
Wrappers:
|
||||
- `updatePractice(...)`
|
||||
- `updatePracticeStatus(...)`
|
||||
- `deletePractice(...)`
|
||||
|
||||
---
|
||||
|
||||
## 8) Question sets and questions
|
||||
|
||||
## 8.1 Question sets
|
||||
- `GET /question-sets` with optional query params
|
||||
- `GET /question-sets/by-owner`
|
||||
- `GET /question-sets/:id`
|
||||
- `PUT /question-sets/:id`
|
||||
- `DELETE /question-sets/:id`
|
||||
- `POST /question-sets`
|
||||
|
||||
Wrappers:
|
||||
- `getQuestionSets(params?)`
|
||||
- `getQuestionSetsByOwner(ownerType, ownerId)`
|
||||
- `getQuestionSetById(questionSetId)`
|
||||
- `createQuestionSet(data)`
|
||||
- `updateQuestionSet(questionSetId, partialData)`
|
||||
- `deleteQuestionSet(questionSetId)`
|
||||
|
||||
## 8.2 Question list within set
|
||||
- `GET /question-sets/:questionSetId/questions`
|
||||
- `POST /question-sets/:questionSetId/questions` (add by question id)
|
||||
|
||||
Wrappers:
|
||||
- `getQuestionSetQuestions(questionSetId)`
|
||||
- `addQuestionToSet(questionSetId, { question_id, display_order? })`
|
||||
|
||||
## 8.3 Questions CRUD
|
||||
- `GET /questions` (filters)
|
||||
- `GET /questions/:questionId`
|
||||
- `POST /questions`
|
||||
- `PUT /questions/:questionId`
|
||||
- `DELETE /questions/:questionId`
|
||||
|
||||
Wrappers:
|
||||
- `getQuestions(params)`
|
||||
- `getQuestionById(questionId)`
|
||||
- `createQuestion(data)`
|
||||
- `updateQuestion(questionId, data)`
|
||||
- `deleteQuestion(questionId)`
|
||||
|
||||
## 8.4 Practice-question convenience wrappers
|
||||
|
||||
`createPracticeQuestion(data)`:
|
||||
1. Creates question via `POST /questions`
|
||||
2. Adds it to practice set via `POST /question-sets/:practiceId/questions`
|
||||
|
||||
`updatePracticeQuestion(questionId, data)`:
|
||||
- maps to `PUT /questions/:questionId`
|
||||
|
||||
`deletePracticeQuestion(questionId)`:
|
||||
- `DELETE /questions/:questionId`
|
||||
|
||||
## 8.5 Practice question listing endpoint variants
|
||||
- `getPracticeQuestions(practiceId)` -> `GET /question-sets/:practiceId/questions`
|
||||
- `getPracticeQuestionsByPractice(practiceId, params)` -> `GET /practices/:practiceId/questions`
|
||||
|
||||
Use the second when you need pagination/filtering by question type.
|
||||
|
||||
---
|
||||
|
||||
## 9) Human language specific APIs
|
||||
|
||||
## 9.1 Human language hierarchy
|
||||
- `getHumanLanguageHierarchy()`
|
||||
- Calls `GET /course-management/hierarchy`
|
||||
- If backend already returns nested `sub_categories`, uses it directly.
|
||||
- If backend returns flat rows, client builds nested structure and enriches each course by:
|
||||
- requesting `/course-management/courses/:courseId/hierarchy`
|
||||
- requesting `/question-sets/by-owner` per sub-module
|
||||
- deriving lessons from question sets where `set_type = "QUIZ"`
|
||||
|
||||
This method is heavier than basic endpoints and can issue many requests.
|
||||
|
||||
## 9.2 Human language lessons by course+level
|
||||
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=...`
|
||||
- wrapper: `getHumanLanguageLessonsByCourse(courseId, cefrLevel)`
|
||||
|
||||
## 9.3 Create human language lesson structure
|
||||
|
||||
`createHumanLanguageLesson(data)` is composed:
|
||||
1. `POST /course-management/levels`
|
||||
2. `POST /course-management/modules`
|
||||
3. `POST /course-management/sub-modules`
|
||||
|
||||
---
|
||||
|
||||
## 10) Learning path and assessments
|
||||
|
||||
- `GET /course-management/courses/:courseId/learning-path`
|
||||
- wrapper: `getLearningPath(courseId)`
|
||||
|
||||
- `GET /question-sets/sub-courses/:subModuleId/entry-assessment`
|
||||
- wrapper: `getSubModuleEntryAssessment(subModuleId)`
|
||||
|
||||
---
|
||||
|
||||
## 11) Unsupported or stubbed features in current frontend API layer
|
||||
|
||||
The following wrappers are intentionally stubbed in frontend and return resolved promises (no real backend call):
|
||||
- `getSubModulePrerequisites`
|
||||
- `addSubModulePrerequisite`
|
||||
- `removeSubModulePrerequisite`
|
||||
- `reorderCategories`
|
||||
- `reorderCourses`
|
||||
- `reorderSubModules`
|
||||
- `reorderVideos`
|
||||
- `reorderPractices`
|
||||
|
||||
Implication:
|
||||
- UI may appear to support these flows, but persistence is not implemented through backend yet.
|
||||
|
||||
---
|
||||
|
||||
## 12) Legacy endpoints still exposed (backward compatibility)
|
||||
|
||||
These are still present in `courses.api.ts` but marked deprecated in types:
|
||||
- Programs APIs
|
||||
- Old levels APIs
|
||||
- Old modules APIs
|
||||
- Practices by level/module APIs
|
||||
|
||||
Prefer unified hierarchy/sub-module/question-set APIs for new work.
|
||||
|
||||
---
|
||||
|
||||
## 13) Integration patterns and recommendations
|
||||
|
||||
## 13.1 Safe creation flows
|
||||
- For practice/lesson creation, keep composed behavior:
|
||||
- create question set first
|
||||
- then link to sub-module entity
|
||||
- Handle partial failure:
|
||||
- if link step fails after question set creation, frontend should show recoverable error and optionally support manual relink.
|
||||
|
||||
## 13.2 Request normalization
|
||||
- `getQuestionSetsResponse.data` can be either:
|
||||
- raw array
|
||||
- object with `question_sets`
|
||||
- Normalize before rendering.
|
||||
|
||||
## 13.3 Question type mapping
|
||||
- UI uses `"SHORT"`; backend commonly expects `"SHORT_ANSWER"`.
|
||||
- Existing wrappers already map `"SHORT"` to `"SHORT_ANSWER"` on create/update practice question.
|
||||
|
||||
## 13.4 Media handling
|
||||
- Prefer using `/files/upload` wrappers for all media.
|
||||
- For Vimeo-backed responses, frontend typically consumes `embed_url` (and may append hash from page URL where applicable).
|
||||
|
||||
## 13.5 Retry behavior
|
||||
- Some hierarchy fetches use single retry (`withSingleRetry`) for resiliency against transient auth/network race conditions.
|
||||
|
||||
---
|
||||
|
||||
## 14) Quick endpoint index
|
||||
|
||||
### Course management
|
||||
- `GET /course-management/hierarchy`
|
||||
- `POST /course-management/categories`
|
||||
- `POST /course-management/sub-categories`
|
||||
- `DELETE /course-management/categories/:id`
|
||||
- `DELETE /course-management/sub-categories/:id`
|
||||
- `POST /course-management/courses`
|
||||
- `PUT /course-management/courses/:id`
|
||||
- `DELETE /course-management/courses/:id`
|
||||
- `POST /course-management/courses/:id/thumbnail`
|
||||
- `GET /course-management/courses/:courseId/hierarchy`
|
||||
- `POST /course-management/levels`
|
||||
- `POST /course-management/modules`
|
||||
- `PUT /course-management/levels/:id`
|
||||
- `DELETE /course-management/levels/:id`
|
||||
- `PUT /course-management/modules/:id`
|
||||
- `DELETE /course-management/modules/:id`
|
||||
- `POST /course-management/sub-modules`
|
||||
- `PUT /course-management/sub-modules/:id`
|
||||
- `DELETE /course-management/sub-modules/:id`
|
||||
- `GET /course-management/sub-modules/:subModuleId/videos`
|
||||
- `POST /course-management/sub-module-videos`
|
||||
- `PUT /course-management/sub-module-videos/:id`
|
||||
- `DELETE /course-management/sub-module-videos/:id`
|
||||
- `POST /course-management/sub-module-practices`
|
||||
- `POST /course-management/sub-module-lessons`
|
||||
- `GET /course-management/courses/:courseId/learning-path`
|
||||
- `GET /course-management/human-language/courses/:courseId/lessons`
|
||||
|
||||
### Question sets and questions
|
||||
- `GET /question-sets`
|
||||
- `GET /question-sets/by-owner`
|
||||
- `GET /question-sets/:id`
|
||||
- `POST /question-sets`
|
||||
- `PUT /question-sets/:id`
|
||||
- `DELETE /question-sets/:id`
|
||||
- `GET /question-sets/:id/questions`
|
||||
- `POST /question-sets/:id/questions`
|
||||
- `GET /practices/:practiceId/questions`
|
||||
- `GET /questions`
|
||||
- `GET /questions/:id`
|
||||
- `POST /questions`
|
||||
- `PUT /questions/:id`
|
||||
- `DELETE /questions/:id`
|
||||
- `POST /questions/audio-answer`
|
||||
|
||||
### File/media
|
||||
- `POST /files/upload`
|
||||
- `GET /files/url`
|
||||
- `GET /vimeo/sample`
|
||||
- `POST /vimeo/uploads/pull`
|
||||
|
||||
---
|
||||
|
||||
## 15) Suggested frontend service contract shape
|
||||
|
||||
For any new frontend module, follow this contract:
|
||||
- **Input DTOs**: UI-friendly types (can include UI aliases like `SHORT`)
|
||||
- **Mapper layer**: convert UI DTOs to backend DTOs
|
||||
- **Transport layer**: pure API calls
|
||||
- **Normalizer layer**: normalize polymorphic responses (`array` vs `object`)
|
||||
- **Error policy**:
|
||||
- show user-actionable toast
|
||||
- preserve enough context to retry failed composed steps
|
||||
|
||||
This keeps integration robust even with mixed legacy/unified backend surfaces.
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -23,6 +23,7 @@
|
|||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-is": "^19.2.5",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -5315,11 +5316,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
||||
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
||||
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-is": "^19.2.5",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
|
|||
|
|
@ -44,18 +44,49 @@ import type {
|
|||
GetQuestionsResponse,
|
||||
CreateVimeoVideoRequest,
|
||||
CreateCourseCategoryRequest,
|
||||
GetCategorySubCategoriesResponse,
|
||||
GetSubCategoryCoursesResponse,
|
||||
GetSubCoursePrerequisitesResponse,
|
||||
AddSubCoursePrerequisiteRequest,
|
||||
GetLearningPathResponse,
|
||||
GetHumanLanguageLessonsResponse,
|
||||
GetHumanLanguageHierarchyResponse,
|
||||
GetCourseHierarchyResponse,
|
||||
CreateHumanLanguageLessonRequest,
|
||||
GetSubModuleLessonsResponse,
|
||||
GetSubModuleLessonDetailResponse,
|
||||
UpdateSubModuleLessonRequest,
|
||||
UpdateSubModuleLessonResponse,
|
||||
GetCourseLevelsForCourseResponse,
|
||||
GetSubModulesByModuleResponse,
|
||||
SubCourse,
|
||||
GetSubCourseEntryAssessmentResponse,
|
||||
ReorderItem,
|
||||
GetRatingsResponse,
|
||||
GetRatingsParams,
|
||||
GetVimeoSampleResponse,
|
||||
CreateCourseVideoRequest,
|
||||
GetLearningProgramsResponse,
|
||||
UpdateLearningProgramRequest,
|
||||
CreateLearningProgramRequest,
|
||||
CreateLearningProgramResponse,
|
||||
GetProgramCoursesResponse,
|
||||
GetTopLevelCourseModulesResponse,
|
||||
UpdateTopLevelCourseRequest,
|
||||
UpdateTopLevelCourseModuleRequest,
|
||||
CreateTopLevelCourseModuleRequest,
|
||||
CreateTopLevelCourseModuleResponse,
|
||||
CreateProgramCourseRequest,
|
||||
CreateProgramCourseResponse,
|
||||
GetTopLevelModuleLessonsResponse,
|
||||
GetPracticesByParentContextResponse,
|
||||
CreateParentLinkedPracticeRequest,
|
||||
CreateParentLinkedPracticeResponse,
|
||||
UpdateParentLinkedPracticeRequest,
|
||||
UpdateParentLinkedPracticeResponse,
|
||||
UpdateTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonResponse,
|
||||
} from "../types/course.types"
|
||||
|
||||
type UnifiedHierarchyRow = {
|
||||
|
|
@ -110,6 +141,35 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
|||
? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name })
|
||||
: http.post("/course-management/categories", { name: data.name })
|
||||
|
||||
export const deleteCourseCategory = (categoryId: number) =>
|
||||
http.delete(`/course-management/categories/${categoryId}`)
|
||||
|
||||
export const getSubCategoriesByCategoryId = (categoryId: number) =>
|
||||
http.get<GetCategorySubCategoriesResponse>(`/course-management/categories/${categoryId}/sub-categories`)
|
||||
|
||||
export const getCoursesBySubCategoryId = (subCategoryId: number) =>
|
||||
http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
|
||||
|
||||
export const createSubCategory = (payload: {
|
||||
category_id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
display_order?: number
|
||||
}) => http.post("/course-management/sub-categories", payload)
|
||||
|
||||
export const deleteCourseSubCategory = (subCategoryId: number) =>
|
||||
http.delete(`/course-management/sub-categories/${subCategoryId}`)
|
||||
|
||||
export const updateSubCategory = (
|
||||
subCategoryId: number,
|
||||
payload: Partial<{
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
display_order: number
|
||||
}>,
|
||||
) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
|
||||
|
||||
export const getCoursesByCategory = (categoryId: number) =>
|
||||
http.get("/course-management/hierarchy").then((res) => {
|
||||
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||
|
|
@ -148,9 +208,13 @@ export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
|||
http.put(`/course-management/courses/${courseId}`, data)
|
||||
|
||||
// Sub-Module APIs (Unified Hierarchy)
|
||||
export const getCourseHierarchyByCourseId = (courseId: number) =>
|
||||
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
|
||||
|
||||
export const getSubModulesByCourse = (courseId: number) =>
|
||||
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
|
||||
const rows: CourseHierarchyRow[] = res.data?.data ?? []
|
||||
const raw = res.data?.data
|
||||
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
|
||||
const subModuleMap = new Map<number, { id: number; course_id: number; module_id?: number; title: string; description: string; level: string; cefr_level?: string; thumbnail: string; display_order: number; sub_level?: string; is_active: boolean }>()
|
||||
rows.forEach((r, idx) => {
|
||||
if (!r.sub_module_id) return
|
||||
|
|
@ -225,6 +289,27 @@ export const deleteSubModule = (subModuleId: number) =>
|
|||
export const getVideosBySubModule = (subModuleId: number) =>
|
||||
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
|
||||
|
||||
export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) =>
|
||||
http.get<GetSubModuleLessonsResponse>(`/course-management/sub-modules/${subModuleId}/lessons`, {
|
||||
params: { include_inactive: options?.includeInactive ?? true },
|
||||
})
|
||||
|
||||
export const getSubModuleLessonById = (
|
||||
lessonId: number,
|
||||
options?: { cacheBust?: boolean },
|
||||
) =>
|
||||
http.get<GetSubModuleLessonDetailResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
|
||||
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
||||
})
|
||||
|
||||
export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) =>
|
||||
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, data)
|
||||
|
||||
export const softDeleteSubModuleLesson = (lessonId: number) =>
|
||||
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
|
||||
is_active: false,
|
||||
})
|
||||
|
||||
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
||||
http.post("/course-management/sub-module-videos", {
|
||||
sub_module_id: data.sub_module_id ?? data.sub_course_id,
|
||||
|
|
@ -345,6 +430,126 @@ export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQ
|
|||
export const deletePracticeQuestion = (questionId: number) =>
|
||||
http.delete(`/questions/${questionId}`)
|
||||
|
||||
/** Top-level learning programs (Learn English cards, etc.) — GET /programs */
|
||||
export const getLearningPrograms = (params?: { limit?: number; offset?: number }) =>
|
||||
http.get<GetLearningProgramsResponse>("/programs", { params })
|
||||
|
||||
export const createLearningProgram = (data: CreateLearningProgramRequest) =>
|
||||
http.post<CreateLearningProgramResponse>("/programs", data)
|
||||
|
||||
export const getProgramCourses = (
|
||||
programId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) => http.get<GetProgramCoursesResponse>(`/programs/${programId}/courses`, { params })
|
||||
|
||||
export const createProgramCourse = (
|
||||
programId: number,
|
||||
data: CreateProgramCourseRequest,
|
||||
) => http.post<CreateProgramCourseResponse>(`/programs/${programId}/courses`, data)
|
||||
|
||||
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
||||
http.put(`/courses/${courseId}`, data)
|
||||
|
||||
export const deleteTopLevelCourse = (courseId: number) =>
|
||||
http.delete(`/courses/${courseId}`)
|
||||
|
||||
export const getTopLevelCourseModules = (
|
||||
courseId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetTopLevelCourseModulesResponse>(`/courses/${courseId}/modules`, {
|
||||
params,
|
||||
})
|
||||
|
||||
/** Learn English top-level module — POST /courses/:courseId/modules */
|
||||
export const createTopLevelCourseModule = (
|
||||
courseId: number,
|
||||
data: CreateTopLevelCourseModuleRequest,
|
||||
) =>
|
||||
http.post<CreateTopLevelCourseModuleResponse>(
|
||||
`/courses/${courseId}/modules`,
|
||||
data,
|
||||
)
|
||||
|
||||
/** Learn English top-level module — PUT /modules/:id */
|
||||
export const updateTopLevelCourseModule = (
|
||||
moduleId: number,
|
||||
data: UpdateTopLevelCourseModuleRequest,
|
||||
) => http.put(`/modules/${moduleId}`, data)
|
||||
|
||||
/** Learn English top-level module — DELETE /modules/:id */
|
||||
export const deleteTopLevelCourseModule = (moduleId: number) =>
|
||||
http.delete(`/modules/${moduleId}`)
|
||||
|
||||
/** Learn English top-level module lessons — GET /modules/:moduleId/lessons */
|
||||
export const getModuleLessons = (
|
||||
moduleId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetTopLevelModuleLessonsResponse>(`/modules/${moduleId}/lessons`, {
|
||||
params,
|
||||
})
|
||||
|
||||
/** Learn English top-level module lesson — POST /modules/:moduleId/lessons */
|
||||
export const createModuleLesson = (
|
||||
moduleId: number,
|
||||
data: CreateTopLevelModuleLessonRequest,
|
||||
) =>
|
||||
http.post<CreateTopLevelModuleLessonResponse>(`/modules/${moduleId}/lessons`, data)
|
||||
|
||||
/** Learn English top-level module lesson — PUT /lessons/:id */
|
||||
export const updateTopLevelModuleLesson = (
|
||||
lessonId: number,
|
||||
data: UpdateTopLevelModuleLessonRequest,
|
||||
) => http.put(`/lessons/${lessonId}`, data)
|
||||
|
||||
/** Learn English top-level module lesson — DELETE /lessons/:id */
|
||||
export const deleteTopLevelModuleLesson = (lessonId: number) =>
|
||||
http.delete(`/lessons/${lessonId}`)
|
||||
|
||||
/** GET /courses/:courseId/practices — practices linked to a top-level course (at most one in normal use). */
|
||||
export const getPracticesByParentCourse = (
|
||||
courseId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetPracticesByParentContextResponse>(`/courses/${courseId}/practices`, { params })
|
||||
|
||||
/** GET /modules/:moduleId/practices */
|
||||
export const getPracticesByParentModule = (
|
||||
moduleId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetPracticesByParentContextResponse>(`/modules/${moduleId}/practices`, { params })
|
||||
|
||||
/** GET /lessons/:lessonId/practices */
|
||||
export const getPracticesByParentLesson = (
|
||||
lessonId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetPracticesByParentContextResponse>(`/lessons/${lessonId}/practices`, { params })
|
||||
|
||||
/** POST /practices — create a practice (story + question set) for course / module / lesson. */
|
||||
export const createParentLinkedPractice = (data: CreateParentLinkedPracticeRequest) =>
|
||||
http.post<CreateParentLinkedPracticeResponse>("/practices", data)
|
||||
|
||||
/** PUT /practices/:id */
|
||||
export const updateParentLinkedPractice = (
|
||||
practiceId: number,
|
||||
data: UpdateParentLinkedPracticeRequest,
|
||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
||||
|
||||
/** DELETE /practices/:id */
|
||||
export const deleteParentLinkedPractice = (practiceId: number) =>
|
||||
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
||||
`/practices/${practiceId}`,
|
||||
)
|
||||
|
||||
export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) =>
|
||||
http.put(`/programs/${programId}`, data)
|
||||
|
||||
export const deleteLearningProgram = (programId: number) => http.delete(`/programs/${programId}`)
|
||||
|
||||
// ============================================
|
||||
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
||||
// Keeping for backward compatibility
|
||||
|
|
@ -383,6 +588,74 @@ export const deleteLevel = (levelId: number) =>
|
|||
export const getModulesByLevel = (levelId: number) =>
|
||||
http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`)
|
||||
|
||||
export const getCourseLevelsForCourse = (courseId: number) =>
|
||||
http.get<GetCourseLevelsForCourseResponse>(`/course-management/courses/${courseId}/levels`)
|
||||
|
||||
export const getSubModulesByModuleId = (moduleId: number) =>
|
||||
http.get<GetSubModulesByModuleResponse>(`/course-management/modules/${moduleId}/sub-modules`)
|
||||
|
||||
/**
|
||||
* Finds a sub-module under a course by walking levels → modules → sub-modules APIs.
|
||||
*/
|
||||
export async function resolveSubModuleForCourse(
|
||||
courseId: number,
|
||||
subModuleId: number,
|
||||
): Promise<SubCourse | null> {
|
||||
try {
|
||||
const levelsRes = await getCourseLevelsForCourse(courseId)
|
||||
const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
|
||||
const sortedLevels = [...levels].sort((a, b) => {
|
||||
const o = (a.display_order ?? 0) - (b.display_order ?? 0)
|
||||
if (o !== 0) return o
|
||||
return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
|
||||
})
|
||||
|
||||
const modulesNested = await Promise.all(
|
||||
sortedLevels.map(async (level) => {
|
||||
const modsRes = await getModulesByLevel(level.id)
|
||||
const rawMods = modsRes.data?.data?.modules
|
||||
const modules = Array.isArray(rawMods) ? rawMods : []
|
||||
const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||
return sortedMods.map((module) => ({ level, module }))
|
||||
}),
|
||||
)
|
||||
const modulePairs = modulesNested.flat()
|
||||
|
||||
const bundles = await Promise.all(
|
||||
modulePairs.map(async ({ level, module }) => {
|
||||
const subsRes = await getSubModulesByModuleId(module.id)
|
||||
const rawSubs = subsRes.data?.data?.sub_modules
|
||||
const subs = Array.isArray(rawSubs) ? rawSubs : []
|
||||
const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||
return { level, module, subs: sortedSubs }
|
||||
}),
|
||||
)
|
||||
|
||||
for (const { level, module, subs } of bundles) {
|
||||
const found = subs.find((s) => s.id === subModuleId)
|
||||
if (found) {
|
||||
return {
|
||||
id: found.id,
|
||||
course_id: courseId,
|
||||
level_id: level.id,
|
||||
module_id: module.id,
|
||||
title: found.title,
|
||||
description: found.description ?? "",
|
||||
level: level.cefr_level,
|
||||
cefr_level: level.cefr_level,
|
||||
thumbnail: found.thumbnail ?? "",
|
||||
display_order: found.display_order,
|
||||
sub_level: level.cefr_level,
|
||||
is_active: found.is_active,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("resolveSubModuleForCourse failed:", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const createModule = (data: CreateModuleRequest) =>
|
||||
http.post("/course-management/modules", data)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ export interface ResolveFileUrlResponse {
|
|||
success?: boolean
|
||||
}
|
||||
|
||||
export interface RefreshFileUrlResponse {
|
||||
message: string
|
||||
data?: {
|
||||
object_key?: string
|
||||
url?: string
|
||||
expires_in?: number
|
||||
}
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
export interface UploadMediaOptions {
|
||||
title?: string
|
||||
description?: string
|
||||
|
|
@ -86,3 +96,8 @@ export const resolveFileUrl = (key: string) =>
|
|||
params: { key },
|
||||
})
|
||||
|
||||
export const refreshFileUrl = (reference: string) =>
|
||||
http.post<RefreshFileUrlResponse>("/files/refresh-url", {
|
||||
reference,
|
||||
})
|
||||
|
||||
|
|
|
|||
136
src/api/http.ts
136
src/api/http.ts
|
|
@ -12,6 +12,7 @@ let failedQueue: Array<{
|
|||
resolve: (token: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
const TOKEN_REFRESH_BUFFER_SECONDS = 120;
|
||||
|
||||
const processQueue = (error: Error | null, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
|
|
@ -32,23 +33,68 @@ const clearAuthAndRedirect = () => {
|
|||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
const role = localStorage.getItem("role");
|
||||
const memberId = localStorage.getItem("member_id");
|
||||
const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const payloadPart = token.split(".")[1];
|
||||
if (!payloadPart) return null;
|
||||
const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const json = atob(padded);
|
||||
return JSON.parse(json) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!refreshToken || !memberId) {
|
||||
const isAccessTokenExpiringSoon = (token: string) => {
|
||||
const payload = decodeJwtPayload(token);
|
||||
const exp = Number(payload?.exp);
|
||||
if (!Number.isFinite(exp)) return true;
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
return exp - nowSeconds <= TOKEN_REFRESH_BUFFER_SECONDS;
|
||||
};
|
||||
|
||||
const isAuthEndpointRequest = (url?: string) => {
|
||||
if (!url) return false;
|
||||
return (
|
||||
url.includes("/team/login") ||
|
||||
url.includes("/team/google-login") ||
|
||||
url.includes("/team/refresh")
|
||||
);
|
||||
};
|
||||
|
||||
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
|
||||
|
||||
const safeOrigin = (url?: string): string | null => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const API_BASE_ORIGIN = safeOrigin(import.meta.env.VITE_API_BASE_URL);
|
||||
|
||||
const shouldAttachApiAuth = (url?: string): boolean => {
|
||||
if (!url) return true;
|
||||
if (!ABSOLUTE_URL_REGEX.test(url)) return true;
|
||||
const requestOrigin = safeOrigin(url);
|
||||
if (!requestOrigin || !API_BASE_ORIGIN) return false;
|
||||
return requestOrigin === API_BASE_ORIGIN;
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
||||
`${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
|
||||
{
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
role: role || "admin",
|
||||
member_id: Number(memberId),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -65,9 +111,47 @@ const refreshAccessToken = async (): Promise<string> => {
|
|||
return newAccessToken;
|
||||
};
|
||||
|
||||
const getValidAccessToken = async (forceRefresh = false): Promise<string> => {
|
||||
const currentToken = localStorage.getItem("access_token");
|
||||
if (!forceRefresh && currentToken && !isAccessTokenExpiringSoon(currentToken)) {
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
processQueue(null, newToken);
|
||||
return newToken;
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError as Error, null);
|
||||
clearAuthAndRedirect();
|
||||
throw refreshError;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Attach access token to every request
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
http.interceptors.request.use(async (config) => {
|
||||
if (!shouldAttachApiAuth(config.url)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
if (isAuthEndpointRequest(config.url)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
let token = localStorage.getItem("access_token");
|
||||
if (token && isAccessTokenExpiringSoon(token)) {
|
||||
token = await getValidAccessToken();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
|
@ -80,37 +164,25 @@ http.interceptors.response.use(
|
|||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return http(originalRequest);
|
||||
})
|
||||
.catch((err) => Promise.reject(err));
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
shouldAttachApiAuth(originalRequest.url) &&
|
||||
!isAuthEndpointRequest(originalRequest.url)
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
processQueue(null, newToken);
|
||||
const newToken = await getValidAccessToken(true);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return http(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError as Error, null);
|
||||
clearAuthAndRedirect();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend is down (network error, timeout, connection refused)
|
||||
if (!error.response) {
|
||||
if (!error.response && shouldAttachApiAuth(originalRequest.url)) {
|
||||
clearAuthAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
GetRolesParams,
|
||||
CreateRoleRequest,
|
||||
CreateRoleResponse,
|
||||
DeleteRoleResponse,
|
||||
SetRolePermissionsRequest,
|
||||
GetPermissionsResponse,
|
||||
} from "../types/rbac.types"
|
||||
|
|
@ -26,3 +27,6 @@ export const setRolePermissions = (roleId: number, data: SetRolePermissionsReque
|
|||
|
||||
export const getAllPermissions = () =>
|
||||
http.get<GetPermissionsResponse>("/rbac/permissions")
|
||||
|
||||
export const deleteRole = (roleId: number) =>
|
||||
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
|
||||
|
|
|
|||
|
|
@ -1,52 +1,67 @@
|
|||
import { Navigate, Route, Routes } from "react-router-dom"
|
||||
import { AppLayout } from "../layouts/AppLayout"
|
||||
import { DashboardPage } from "../pages/DashboardPage"
|
||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
||||
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
|
||||
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"
|
||||
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"
|
||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
||||
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
||||
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
||||
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
|
||||
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
|
||||
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage"
|
||||
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
|
||||
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
|
||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
|
||||
import { NotFoundPage } from "../pages/NotFoundPage"
|
||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage"
|
||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage"
|
||||
import { UserDetailPage } from "../pages/user-management/UserDetailPage"
|
||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
|
||||
import { UsersListPage } from "../pages/user-management/UsersListPage"
|
||||
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard"
|
||||
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage"
|
||||
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage"
|
||||
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout"
|
||||
import { RolesListPage } from "../pages/role-management/RolesListPage"
|
||||
import { AddRolePage } from "../pages/role-management/AddRolePage"
|
||||
import { PracticeDetailsPage } from "../pages/content-management/PracticeDetailsPage"
|
||||
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
|
||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
|
||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
|
||||
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||
import { IssuesPage } from "../pages/issues/IssuesPage"
|
||||
import { ProfilePage } from "../pages/ProfilePage"
|
||||
import { SettingsPage } from "../pages/SettingsPage"
|
||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
||||
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage"
|
||||
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
||||
import { LoginPage } from "../pages/auth/LoginPage"
|
||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
|
||||
import { VerificationPage } from "../pages/auth/VerificationPage"
|
||||
import { AboutPage } from "../pages/AboutPage"
|
||||
import { TermsPage } from "../pages/TermsPage"
|
||||
import { PrivacyPage } from "../pages/PrivacyPage"
|
||||
import { AccountDeletionPage } from "../pages/AccountDeletionPage"
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AppLayout } from "../layouts/AppLayout";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
|
||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
|
||||
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
|
||||
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
|
||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
|
||||
import { CoursesPage } from "../pages/content-management/CoursesPage";
|
||||
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage";
|
||||
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage";
|
||||
import { SubModulesPage } from "../pages/content-management/SubCoursesPage";
|
||||
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage";
|
||||
import { SpeakingPage } from "../pages/content-management/SpeakingPage";
|
||||
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
|
||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
|
||||
import { NewContentPage } from "../pages/content-management/NewContentPage";
|
||||
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
||||
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
||||
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
||||
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
||||
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
||||
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
||||
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
|
||||
import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow";
|
||||
import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow";
|
||||
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
|
||||
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
||||
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
|
||||
import { UnitManagementPage } from "../pages/content-management/UnitManagementPage";
|
||||
import { QuestionTypeLibraryPage } from "../pages/content-management/QuestionTypeLibraryPage";
|
||||
import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuestionTypeFlow";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
||||
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
||||
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
||||
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
|
||||
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
|
||||
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
|
||||
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
|
||||
import { RolesListPage } from "../pages/role-management/RolesListPage";
|
||||
import { AddRolePage } from "../pages/role-management/AddRolePage";
|
||||
import { PracticeDetailsPage } from "../pages/content-management/PracticeDetailsPage";
|
||||
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
|
||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage";
|
||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
|
||||
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage";
|
||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
||||
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
||||
import { IssuesPage } from "../pages/issues/IssuesPage";
|
||||
import { ProfilePage } from "../pages/ProfilePage";
|
||||
import { SettingsPage } from "../pages/SettingsPage";
|
||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
|
||||
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage";
|
||||
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage";
|
||||
import { LoginPage } from "../pages/auth/LoginPage";
|
||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
|
||||
import { VerificationPage } from "../pages/auth/VerificationPage";
|
||||
import { AboutPage } from "../pages/AboutPage";
|
||||
import { TermsPage } from "../pages/TermsPage";
|
||||
import { PrivacyPage } from "../pages/PrivacyPage";
|
||||
import { AccountDeletionPage } from "../pages/AccountDeletionPage";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
|
|
@ -75,10 +90,10 @@ export function AppRoutes() {
|
|||
</Route>
|
||||
|
||||
<Route path="/content" element={<ContentManagementLayout />}>
|
||||
<Route index element={<CourseCategoryPage />} />
|
||||
<Route index element={<Navigate to="practices" replace />} />
|
||||
<Route path="courses" element={<AllCoursesPage />} />
|
||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||
<Route path="human-language" element={<HumanLanguagePage />} />
|
||||
<Route path="human-language" element={<HumanLanguageHierarchyPage />} />
|
||||
<Route
|
||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
||||
element={<AddNewPracticePage />}
|
||||
|
|
@ -91,19 +106,52 @@ export function AppRoutes() {
|
|||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
|
||||
element={<HumanLanguageSubModulePage />}
|
||||
/>
|
||||
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||
<Route
|
||||
path="category/:categoryId"
|
||||
element={<ContentOverviewPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses"
|
||||
element={<CoursesPage />}
|
||||
/>
|
||||
{/* Course → Sub-module → Lesson/Practice */}
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} />
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-modules"
|
||||
element={<SubModulesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId"
|
||||
element={<SubModuleContentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice"
|
||||
element={<AddNewPracticePage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions"
|
||||
element={<PracticeQuestionsPage />}
|
||||
/>
|
||||
{/* Legacy aliases */}
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<SubModuleContentPage />} />
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-courses"
|
||||
element={<SubModulesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId"
|
||||
element={<SubModuleContentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice"
|
||||
element={<AddNewPracticePage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions"
|
||||
element={<PracticeQuestionsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:categoryId/courses/add-video"
|
||||
element={<AddVideoPage />}
|
||||
/>
|
||||
<Route path="speaking" element={<SpeakingPage />} />
|
||||
<Route path="speaking/add-practice" element={<AddPracticePage />} />
|
||||
<Route path="practices" element={<PracticeDetailsPage />} />
|
||||
|
|
@ -113,8 +161,73 @@ export function AppRoutes() {
|
|||
<Route path="questions/edit/:id" element={<AddQuestionPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/new-content" element={<NewContentPage />} />
|
||||
<Route
|
||||
path="/new-content/courses"
|
||||
element={<ProgramTypeSelectionPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/question-types"
|
||||
element={<QuestionTypeLibraryPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/question-types/create"
|
||||
element={<CreateQuestionTypeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType"
|
||||
element={<ProgramDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/attach-practice"
|
||||
element={<AttachProgramPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice"
|
||||
element={<AttachPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId"
|
||||
element={<CourseManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/:unitId"
|
||||
element={<UnitManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
|
||||
element={<CourseModuleDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english"
|
||||
element={<LearnEnglishPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses"
|
||||
element={<ProgramCoursesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/:courseId"
|
||||
element={<CourseDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId"
|
||||
element={<ModuleDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
|
||||
element={<AddVideoFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english/:level/courses/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
/>
|
||||
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/notifications/create" element={<CreateNotificationPage />} />
|
||||
<Route
|
||||
path="/notifications/create"
|
||||
element={<CreateNotificationPage />}
|
||||
/>
|
||||
<Route path="/user-log" element={<UserLogPage />} />
|
||||
<Route path="/issues" element={<IssuesPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
|
|
@ -128,7 +241,5 @@ export function AppRoutes() {
|
|||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
BIN
src/assets/icons/upload.png
Normal file
BIN
src/assets/icons/upload.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
|
|
@ -13,57 +13,65 @@ import {
|
|||
Users,
|
||||
Users2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { type ComponentType, useEffect, useState } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { BrandLogo } from "../brand/BrandLogo"
|
||||
import { getUnreadCount } from "../../api/notifications.api"
|
||||
} from "lucide-react";
|
||||
import { type ComponentType, useEffect, useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { BrandLogo } from "../brand/BrandLogo";
|
||||
import { getUnreadCount } from "../../api/notifications.api";
|
||||
|
||||
type NavItem = {
|
||||
label: string
|
||||
to: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
label: string;
|
||||
to: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||
{ label: "User Management", to: "/users", icon: Users },
|
||||
{ label: "Role Management", to: "/roles", icon: Shield },
|
||||
{ label: "Content Management", to: "/content", icon: BookOpen },
|
||||
{ label: "New Content", to: "/new-content", icon: BookOpen },
|
||||
|
||||
{ label: "Notifications", to: "/notifications", icon: Bell },
|
||||
{ label: "User Log", to: "/user-log", icon: ClipboardList },
|
||||
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
||||
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||
{ label: "Team Management", to: "/team", icon: Users2 },
|
||||
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
||||
]
|
||||
];
|
||||
|
||||
type SidebarProps = {
|
||||
isOpen: boolean
|
||||
isCollapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
isOpen: boolean;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) {
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
export function Sidebar({
|
||||
isOpen,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onClose,
|
||||
}: SidebarProps) {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUnread = async () => {
|
||||
try {
|
||||
const res = await getUnreadCount()
|
||||
setUnreadCount(res.data.unread)
|
||||
const res = await getUnreadCount();
|
||||
setUnreadCount(res.data.unread);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnread()
|
||||
fetchUnread();
|
||||
|
||||
window.addEventListener("notifications-updated", fetchUnread)
|
||||
return () => window.removeEventListener("notifications-updated", fetchUnread)
|
||||
}, [])
|
||||
window.addEventListener("notifications-updated", fetchUnread);
|
||||
return () =>
|
||||
window.removeEventListener("notifications-updated", fetchUnread);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -86,7 +94,12 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between px-2", isCollapsed && "justify-center")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-2",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<span className="h-10 w-10 overflow-hidden">
|
||||
<BrandLogo className="h-10 w-auto max-w-none" />
|
||||
|
|
@ -103,7 +116,11 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
onClick={onToggleCollapse}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="h-5 w-5" /> : <ChevronLeft className="h-5 w-5" />}
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -117,7 +134,7 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
|
||||
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
|
|
@ -143,25 +160,36 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
{isCollapsed &&
|
||||
item.to === "/notifications" &&
|
||||
unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
</span>
|
||||
{!isCollapsed && <span className="truncate">{item.label}</span>}
|
||||
{!isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
{!isCollapsed && (
|
||||
<span className="truncate">{item.label}</span>
|
||||
)}
|
||||
{!isCollapsed && item.to !== "/notifications" && isActive ? (
|
||||
{!isCollapsed &&
|
||||
item.to === "/notifications" &&
|
||||
unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{!isCollapsed &&
|
||||
item.to !== "/notifications" &&
|
||||
isActive ? (
|
||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||
) : !isCollapsed && item.to === "/notifications" && unreadCount === 0 && isActive ? (
|
||||
) : !isCollapsed &&
|
||||
item.to === "/notifications" &&
|
||||
unreadCount === 0 &&
|
||||
isActive ? (
|
||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
|
|
@ -169,8 +197,8 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.clear()
|
||||
window.location.href = "/login"
|
||||
localStorage.clear();
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
|
||||
|
|
@ -184,5 +212,5 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
|
||||
<header className="sticky top-0 z-40 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
|
||||
{/* Sidebar toggle */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
|
|
@ -20,8 +20,8 @@ const DialogOverlay = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
|
|
@ -38,27 +38,42 @@ const DialogContent = React.forwardRef<
|
|||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<DialogPrimitive.Close className="absolute right-6 top-10 rounded-sm opacity-60 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-6 w-6" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
|
|
@ -66,11 +81,14 @@ const DialogTitle = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
|
|
@ -81,8 +99,8 @@ const DialogDescription = React.forwardRef<
|
|||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
|
|
@ -95,5 +113,4 @@ export {
|
|||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-[6px] border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
||||
|
||||
|
|
@ -18,10 +18,9 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|||
>
|
||||
{children}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-600" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
)
|
||||
Select.displayName = "Select"
|
||||
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
|
|
|
|||
|
|
@ -1,61 +1,55 @@
|
|||
import * as React from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface StepperProps {
|
||||
steps: string[]
|
||||
currentStep: number
|
||||
className?: string
|
||||
steps: string[];
|
||||
currentStep: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Stepper({ steps, currentStep, className }: StepperProps) {
|
||||
return (
|
||||
<div className={cn("flex w-full items-center", className)}>
|
||||
<div className={cn("flex w-full items-start justify-between", className)}>
|
||||
{steps.map((step, index) => {
|
||||
const stepNumber = index + 1
|
||||
const isCompleted = stepNumber < currentStep
|
||||
const isCurrent = stepNumber === currentStep
|
||||
const stepNumber = index + 1;
|
||||
const isCurrent = stepNumber === currentStep;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-10 w-10 place-items-center rounded-full border-2 text-sm font-semibold transition-colors",
|
||||
isCompleted && "border-brand-500 bg-brand-500 text-white",
|
||||
// Active step should be visually prominent.
|
||||
isCurrent && "border-brand-500 bg-brand-500 text-white",
|
||||
!isCompleted && !isCurrent && "border-grayScale-300 bg-white text-grayScale-400",
|
||||
)}
|
||||
>
|
||||
{isCompleted ? <Check className="h-5 w-5" /> : stepNumber}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-2 text-xs font-medium",
|
||||
isCurrent && "text-brand-600",
|
||||
!isCurrent && "text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
{step}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key={step}
|
||||
className="flex-1 relative flex flex-col items-center group"
|
||||
>
|
||||
{/* Connector Line - floats between circles with gap on both sides */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
// Keep the connector visually continuous with the step circles.
|
||||
"mx-2 h-0.5 flex-1",
|
||||
// Color the track up to the current step.
|
||||
isCompleted || isCurrent ? "bg-brand-500" : "bg-grayScale-200",
|
||||
)}
|
||||
className="absolute top-4 h-[1.5px] bg-grayScale-200 z-0"
|
||||
style={{ left: "calc(50% + 50px)", right: "calc(-50% + 50px)" }}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
{/* Circle */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 grid h-8 w-8 place-items-center rounded-full border-2 text-sm font-bold transition-all duration-300 mb-3",
|
||||
isCurrent
|
||||
? "border-brand-500 bg-brand-500 text-white shadow-md scale-110"
|
||||
: "border-grayScale-100 bg-white text-grayScale-400 font-medium",
|
||||
)}
|
||||
>
|
||||
{stepNumber}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 text-[12px] font-bold transition-colors duration-300",
|
||||
isCurrent ? "text-brand-500" : "text-grayScale-400 font-medium",
|
||||
)}
|
||||
>
|
||||
{step}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { useState, useCallback } from "react"
|
||||
import { Navigate, Outlet } from "react-router-dom"
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom"
|
||||
import { Sidebar } from "../components/sidebar/Sidebar"
|
||||
import { Topbar } from "../components/topbar/Topbar"
|
||||
|
||||
export function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const mainRef = useRef<HTMLElement | null>(null)
|
||||
const previousRouteKeyRef = useRef<string>("")
|
||||
const location = useLocation()
|
||||
const scrollStoragePrefix = "app:scroll:"
|
||||
const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
|
||||
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const handleSidebarToggle = useCallback(() => {
|
||||
setSidebarOpen((prev) => !prev)
|
||||
|
|
@ -20,6 +22,43 @@ export function AppLayout() {
|
|||
setSidebarOpen(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const container = mainRef.current
|
||||
if (!container) return
|
||||
|
||||
const saveScroll = (key: string) => {
|
||||
sessionStorage.setItem(`${scrollStoragePrefix}${key}`, String(container.scrollTop || 0))
|
||||
}
|
||||
|
||||
const previousKey = previousRouteKeyRef.current
|
||||
if (previousKey && previousKey !== routeKey) {
|
||||
saveScroll(previousKey)
|
||||
}
|
||||
previousRouteKeyRef.current = routeKey
|
||||
|
||||
const restoreRaw = sessionStorage.getItem(`${scrollStoragePrefix}${routeKey}`)
|
||||
const restoreTop = restoreRaw ? Number(restoreRaw) : 0
|
||||
const top = Number.isFinite(restoreTop) && restoreTop > 0 ? restoreTop : 0
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTo({ top, behavior: "auto" })
|
||||
})
|
||||
|
||||
const onScroll = () => saveScroll(routeKey)
|
||||
const onBeforeUnload = () => saveScroll(routeKey)
|
||||
container.addEventListener("scroll", onScroll, { passive: true })
|
||||
window.addEventListener("beforeunload", onBeforeUnload)
|
||||
|
||||
return () => {
|
||||
saveScroll(routeKey)
|
||||
container.removeEventListener("scroll", onScroll)
|
||||
window.removeEventListener("beforeunload", onBeforeUnload)
|
||||
}
|
||||
}, [routeKey])
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-grayScale-100">
|
||||
<Sidebar
|
||||
|
|
@ -34,7 +73,7 @@ export function AppLayout() {
|
|||
}`}
|
||||
>
|
||||
<Topbar onSidebarToggle={handleSidebarToggle} />
|
||||
<main className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
|
||||
<main ref={mainRef} className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
|
||||
|
|
|
|||
13
src/lib/sessionRole.ts
Normal file
13
src/lib/sessionRole.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const ADMIN_OR_SUPER: ReadonlySet<string> = new Set([
|
||||
"admin",
|
||||
"super_admin",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when the stored session role is admin or super_admin (login stores `role` in localStorage).
|
||||
*/
|
||||
export function isAdminOrSuperAdminRole(): boolean {
|
||||
const raw = localStorage.getItem("role");
|
||||
if (!raw) return false;
|
||||
return ADMIN_OR_SUPER.has(raw.trim().toLowerCase());
|
||||
}
|
||||
124
src/lib/videoPreview.ts
Normal file
124
src/lib/videoPreview.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Resolves a user-facing video URL into something we can preview (iframe or <video>).
|
||||
*/
|
||||
|
||||
export function toVimeoEmbedUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl.trim());
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (!host.includes("vimeo.com")) return null;
|
||||
if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) {
|
||||
return parsed.toString();
|
||||
}
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const videoId = segments.find((segment) => /^\d+$/.test(segment));
|
||||
if (!videoId) return null;
|
||||
const hash = parsed.searchParams.get("h");
|
||||
return hash
|
||||
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
|
||||
: `https://player.vimeo.com/video/${videoId}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toYoutubeEmbedUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(rawUrl.trim());
|
||||
const host = u.hostname.replace(/^www\./, "").toLowerCase();
|
||||
if (host === "youtu.be") {
|
||||
const id = u.pathname.split("/").filter(Boolean)[0];
|
||||
if (id) return `https://www.youtube.com/embed/${id}`;
|
||||
}
|
||||
if (host === "youtube.com" || host === "m.youtube.com") {
|
||||
const v = u.searchParams.get("v");
|
||||
if (v) return `https://www.youtube.com/embed/${v}`;
|
||||
let m = u.pathname.match(/\/embed\/([^/]+)/);
|
||||
if (m) return `https://www.youtube.com/embed/${m[1]}`;
|
||||
m = u.pathname.match(/\/shorts\/([^/]+)/);
|
||||
if (m) return `https://www.youtube.com/embed/${m[1]}`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isDirectVideoFileUrl(url: string): boolean {
|
||||
const clean = url.split("?")[0].toLowerCase();
|
||||
return /^https?:\/\//.test(url.trim()) && /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
|
||||
}
|
||||
|
||||
export type VideoPreviewKind =
|
||||
| { kind: "iframe"; src: string; label: "Vimeo" | "YouTube" }
|
||||
| { kind: "video"; src: string }
|
||||
| { kind: "none" };
|
||||
|
||||
export function getVideoPreview(url: string): VideoPreviewKind {
|
||||
const t = url.trim();
|
||||
if (!t) return { kind: "none" };
|
||||
const vimeo = toVimeoEmbedUrl(t);
|
||||
if (vimeo) return { kind: "iframe", src: vimeo, label: "Vimeo" };
|
||||
const yt = toYoutubeEmbedUrl(t);
|
||||
if (yt) return { kind: "iframe", src: yt, label: "YouTube" };
|
||||
if (isDirectVideoFileUrl(t)) return { kind: "video", src: t };
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
/**
|
||||
* First N seconds only — embed “short” preview in admin cards / review, not the full file.
|
||||
* @see https://developers.google.com/youtube/player_parameters (end, start)
|
||||
*/
|
||||
export const DEFAULT_PREVIEW_MAX_SECONDS = 60;
|
||||
|
||||
export function formatPreviewLength(totalSeconds: number): string {
|
||||
if (totalSeconds < 60) return `${totalSeconds} seconds`;
|
||||
if (totalSeconds % 60 === 0) {
|
||||
const m = totalSeconds / 60;
|
||||
return m === 1 ? "1 minute" : `${m} minutes`;
|
||||
}
|
||||
return `${totalSeconds} seconds`;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube: `end` = stop after this many seconds from the start of the video.
|
||||
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
|
||||
*/
|
||||
export function applyShortPreviewToEmbedUrl(
|
||||
embedUrl: string,
|
||||
label: "Vimeo" | "YouTube",
|
||||
maxSeconds: number = DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
): string {
|
||||
try {
|
||||
if (label === "YouTube") {
|
||||
const u = new URL(embedUrl);
|
||||
u.searchParams.set("start", "0");
|
||||
u.searchParams.set("end", String(maxSeconds));
|
||||
u.searchParams.set("rel", u.searchParams.get("rel") ?? "0");
|
||||
return u.toString();
|
||||
}
|
||||
if (label === "Vimeo") {
|
||||
const u = new URL(embedUrl);
|
||||
u.searchParams.set("start", "0");
|
||||
u.searchParams.set("end", String(maxSeconds));
|
||||
u.hash = `t=0,${maxSeconds}`;
|
||||
return u.toString();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return embedUrl;
|
||||
}
|
||||
|
||||
/** Google Drive "view" links are not direct image URLs; use the thumbnail API for preview. */
|
||||
export function resolveThumbnailForPreview(
|
||||
url: string | null | undefined,
|
||||
): string | null {
|
||||
if (!url?.trim()) return null;
|
||||
const t = url.trim();
|
||||
const m = t.match(/\/file\/d\/([a-zA-Z0-9_-]+)/);
|
||||
if (m) {
|
||||
return `https://drive.google.com/thumbnail?id=${m[1]}&sz=w800`;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
|
@ -67,9 +67,6 @@ export function LoginPage() {
|
|||
const navigate = useNavigate();
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
|
|
@ -162,6 +159,10 @@ export function LoginPage() {
|
|||
}
|
||||
}, [googleReady, handleGoogleCallback]);
|
||||
|
||||
if (token) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
|
|||
650
src/pages/content-management/AddNewLessonPage.tsx
Normal file
650
src/pages/content-management/AddNewLessonPage.tsx
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
import { useMemo, useState, type ChangeEvent } from "react"
|
||||
import { ArrowLeft, ArrowRight, Check, GripVertical, Plus, Rocket, Trash2, Upload } from "lucide-react"
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
|
||||
import { uploadVideoFile } from "../../api/files.api"
|
||||
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import type { QuestionOption } from "../../types/course.types"
|
||||
|
||||
type Step = 1 | 2 | 3 | 4
|
||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||
type ResultStatus = "success" | "error"
|
||||
|
||||
interface MCQOption {
|
||||
text: string
|
||||
isCorrect: boolean
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
questionText: string
|
||||
questionType: QuestionType
|
||||
difficultyLevel: DifficultyLevel
|
||||
points: number
|
||||
tips: string
|
||||
explanation: string
|
||||
options: MCQOption[]
|
||||
voicePrompt: string
|
||||
sampleAnswerVoicePrompt: string
|
||||
audioCorrectAnswerText: string
|
||||
shortAnswers: string[]
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ number: 1, label: "Context" },
|
||||
{ number: 2, label: "Questions" },
|
||||
{ number: 3, label: "Review" },
|
||||
]
|
||||
|
||||
function createEmptyQuestion(id: string): Question {
|
||||
return {
|
||||
id,
|
||||
questionText: "",
|
||||
questionType: "MCQ",
|
||||
difficultyLevel: "EASY",
|
||||
points: 1,
|
||||
tips: "",
|
||||
explanation: "",
|
||||
options: [
|
||||
{ text: "", isCorrect: true },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
],
|
||||
voicePrompt: "",
|
||||
sampleAnswerVoicePrompt: "",
|
||||
audioCorrectAnswerText: "",
|
||||
shortAnswers: [],
|
||||
imageUrl: "",
|
||||
}
|
||||
}
|
||||
|
||||
function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null {
|
||||
if (!data) return null
|
||||
const pageUrl = data.url?.trim()
|
||||
const embedUrl = data.embed_url?.trim()
|
||||
if (embedUrl) {
|
||||
const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined
|
||||
return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
|
||||
}
|
||||
return pageUrl || null
|
||||
}
|
||||
|
||||
function toVimeoEmbedUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl.trim())
|
||||
const host = parsed.hostname.toLowerCase()
|
||||
if (!host.includes("vimeo.com")) return null
|
||||
if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) return parsed.toString()
|
||||
const segments = parsed.pathname.split("/").filter(Boolean)
|
||||
const videoId = segments.find((segment) => /^\d+$/.test(segment))
|
||||
if (!videoId) return null
|
||||
const hash = parsed.searchParams.get("h")
|
||||
return hash
|
||||
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
|
||||
: `https://player.vimeo.com/video/${videoId}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectVideoFile(url: string): boolean {
|
||||
const clean = url.split("?")[0].toLowerCase()
|
||||
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean)
|
||||
}
|
||||
|
||||
function questionTypeLabel(type: QuestionType): string {
|
||||
if (type === "TRUE_FALSE") return "True/False"
|
||||
if (type === "SHORT") return "Short Answer"
|
||||
if (type === "AUDIO") return "Audio"
|
||||
return "Multiple Choice"
|
||||
}
|
||||
|
||||
export function AddNewLessonPage() {
|
||||
const { categoryId, courseId, subModuleId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const backTo = useMemo(() => {
|
||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
||||
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
||||
}
|
||||
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
||||
}, [categoryId, courseId, subModuleId, location.pathname])
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Step>(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [resultStatus, setResultStatus] = useState<ResultStatus | null>(null)
|
||||
const [resultMessage, setResultMessage] = useState("")
|
||||
const [lastSavedStatus, setLastSavedStatus] = useState<"DRAFT" | "PUBLISHED" | null>(null)
|
||||
|
||||
const [lessonTitle, setLessonTitle] = useState("")
|
||||
const [lessonDescription, setLessonDescription] = useState("")
|
||||
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
||||
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
||||
const [questions, setQuestions] = useState<Question[]>([createEmptyQuestion("1")])
|
||||
|
||||
const handleNext = () => setCurrentStep((s) => (s < 3 ? ((s + 1) as Step) : s))
|
||||
const handleBack = () => setCurrentStep((s) => (s > 1 ? ((s - 1) as Step) : s))
|
||||
|
||||
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file) return
|
||||
setUploadingIntroVideo(true)
|
||||
try {
|
||||
const uploadRes = await uploadVideoFile(file, {
|
||||
title: lessonTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Lesson intro",
|
||||
description: lessonDescription.trim() || undefined,
|
||||
})
|
||||
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
|
||||
if (!finalUrl) throw new Error("Missing uploaded video url")
|
||||
setIntroVideoUrl(finalUrl)
|
||||
toast.success("Intro video uploaded")
|
||||
} catch (error) {
|
||||
console.error("Failed to upload lesson intro video:", error)
|
||||
toast.error("Failed to upload intro video")
|
||||
} finally {
|
||||
setUploadingIntroVideo(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIntroVideoUrlBlur = async () => {
|
||||
const source = introVideoUrl.trim()
|
||||
if (!source || !/^https?:\/\//i.test(source)) return
|
||||
const vimeoEmbed = toVimeoEmbedUrl(source)
|
||||
if (vimeoEmbed) {
|
||||
setIntroVideoUrl(vimeoEmbed)
|
||||
return
|
||||
}
|
||||
if (isDirectVideoFile(source)) {
|
||||
setIntroVideoUrl(source)
|
||||
return
|
||||
}
|
||||
|
||||
// For non-direct URLs, automatically try server-side import via /files/upload.
|
||||
setUploadingIntroVideo(true)
|
||||
try {
|
||||
const uploadRes = await uploadVideoFile(source, {
|
||||
title: lessonTitle.trim() || "Lesson intro",
|
||||
description: lessonDescription.trim() || undefined,
|
||||
})
|
||||
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
|
||||
if (!finalUrl) throw new Error("Missing uploaded video url")
|
||||
setIntroVideoUrl(finalUrl)
|
||||
toast.success("Intro video URL imported")
|
||||
} catch (error) {
|
||||
console.error("Failed to import intro video URL:", error)
|
||||
toast.error("Failed to import intro video URL")
|
||||
} finally {
|
||||
setUploadingIntroVideo(false)
|
||||
}
|
||||
}
|
||||
|
||||
const introVideoPreview = useMemo(() => {
|
||||
const raw = introVideoUrl.trim()
|
||||
if (!raw) return null
|
||||
const vimeoEmbedUrl = toVimeoEmbedUrl(raw)
|
||||
if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl }
|
||||
if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw }
|
||||
return null
|
||||
}, [introVideoUrl])
|
||||
|
||||
const reviewQuestions = useMemo(() => questions, [questions])
|
||||
|
||||
const addQuestion = () => setQuestions((prev) => [...prev, createEmptyQuestion(String(Date.now()))])
|
||||
const removeQuestion = (id: string) => setQuestions((prev) => (prev.length > 1 ? prev.filter((q) => q.id !== id) : prev))
|
||||
const updateQuestion = (id: string, updates: Partial<Question>) =>
|
||||
setQuestions((prev) => prev.map((q) => (q.id === id ? { ...q, ...updates } : q)))
|
||||
|
||||
const saveLesson = async (status: "DRAFT" | "PUBLISHED") => {
|
||||
if (!subModuleId) {
|
||||
toast.error("Missing sub-module id")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const lessonRes = await createLesson({
|
||||
sub_module_id: Number(subModuleId),
|
||||
title: lessonTitle.trim() || "Untitled Lesson",
|
||||
description: lessonDescription.trim() || undefined,
|
||||
intro_video_url: introVideoUrl.trim() || undefined,
|
||||
status,
|
||||
})
|
||||
|
||||
const questionSetId = lessonRes.data?.data?.id
|
||||
if (questionSetId) {
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i]
|
||||
if (!q.questionText.trim()) continue
|
||||
const options: QuestionOption[] =
|
||||
q.questionType === "MCQ"
|
||||
? q.options.map((opt, idx) => ({
|
||||
option_order: idx + 1,
|
||||
option_text: opt.text,
|
||||
is_correct: opt.isCorrect,
|
||||
}))
|
||||
: []
|
||||
|
||||
const qRes = await createQuestion({
|
||||
question_text: q.questionText,
|
||||
question_type: q.questionType,
|
||||
difficulty_level: q.difficultyLevel,
|
||||
points: q.points,
|
||||
tips: q.tips || undefined,
|
||||
explanation: q.explanation || undefined,
|
||||
status: "PUBLISHED",
|
||||
options: options.length > 0 ? options : undefined,
|
||||
voice_prompt: q.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
||||
image_url: q.imageUrl.trim() || undefined,
|
||||
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||
})
|
||||
const questionId = qRes.data?.data?.id
|
||||
if (questionId) {
|
||||
await addQuestionToSet(questionSetId, { question_id: questionId, display_order: i + 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResultStatus("success")
|
||||
setResultMessage(status === "PUBLISHED" ? "Lesson published successfully." : "Lesson saved as draft.")
|
||||
setLastSavedStatus(status)
|
||||
setCurrentStep(4)
|
||||
} catch (error) {
|
||||
console.error("Failed to save lesson:", error)
|
||||
setResultStatus("error")
|
||||
setResultMessage(error instanceof Error ? error.message : "Failed to save lesson")
|
||||
setLastSavedStatus(null)
|
||||
setCurrentStep(4)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
{currentStep !== 4 ? (
|
||||
<>
|
||||
<Link
|
||||
to={backTo}
|
||||
className="group inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-grayScale-600 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||
Back to Sub-course
|
||||
</Link>
|
||||
<div className="border-b border-grayScale-100 pb-6 sm:pb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add New Lesson</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-grayScale-500 sm:text-[15px]">
|
||||
Create a lesson backed by `question_sets` and attach it through `sub_module_lessons`.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-3 py-4 sm:px-6 sm:py-5">
|
||||
{STEPS.map((step, index) => (
|
||||
<div key={step.number} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold shadow-sm transition-all duration-300 sm:h-10 sm:w-10 sm:text-sm ${
|
||||
currentStep === step.number
|
||||
? "bg-brand-500 text-white ring-4 ring-brand-100"
|
||||
: currentStep > step.number
|
||||
? "bg-brand-500 text-white"
|
||||
: "border-2 border-grayScale-300 bg-white text-grayScale-400"
|
||||
}`}
|
||||
>
|
||||
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
|
||||
</div>
|
||||
<span className="mt-2 text-xs font-semibold text-grayScale-500">{step.label}</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 ? (
|
||||
<div className={`mx-4 h-0.5 w-20 ${currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"}`} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{currentStep === 1 ? (
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Context</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||
Define lesson metadata that will be stored in the linked question set.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 sm:p-8 lg:p-10">
|
||||
<div className="mt-5 grid gap-8 lg:grid-cols-12">
|
||||
<div className="space-y-4 lg:col-span-7">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Lesson title</label>
|
||||
<Input
|
||||
value={lessonTitle}
|
||||
onChange={(e) => setLessonTitle(e.target.value)}
|
||||
placeholder="Enter lesson title"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||
<textarea
|
||||
value={lessonDescription}
|
||||
onChange={(e) => setLessonDescription(e.target.value)}
|
||||
className="min-h-[96px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-grayScale-400 focus:outline-none focus:ring-2 focus:ring-grayScale-100"
|
||||
placeholder="Enter lesson description"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Intro video URL (optional)</label>
|
||||
<Input
|
||||
value={introVideoUrl}
|
||||
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
||||
onBlur={() => void handleIntroVideoUrlBlur()}
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
className="font-mono text-[13px]"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50">
|
||||
{uploadingIntroVideo ? <SpinnerIcon className="h-4 w-4" alt="" /> : <Upload className="h-4 w-4" />}
|
||||
{uploadingIntroVideo ? "Uploading..." : "Upload video from computer"}
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleIntroVideoFileChange}
|
||||
disabled={uploadingIntroVideo}
|
||||
/>
|
||||
</label>
|
||||
{introVideoUrl.trim() ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setIntroVideoUrl("")}>
|
||||
Clear URL
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{introVideoPreview ? (
|
||||
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-grayScale-500">Preview</p>
|
||||
{introVideoPreview.kind === "vimeo" ? (
|
||||
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
|
||||
<iframe
|
||||
src={introVideoPreview.url}
|
||||
title="Intro video preview"
|
||||
className="aspect-video w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
controls
|
||||
src={introVideoPreview.url}
|
||||
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<aside className="space-y-4 lg:col-span-5">
|
||||
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-5 shadow-sm">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Lesson schema mapping</h3>
|
||||
<div className="mt-3 space-y-2 text-sm text-grayScale-700">
|
||||
<p>
|
||||
<span className="font-medium">question_sets.title</span> ← Lesson title
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">question_sets.description</span> ← Description
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">question_sets.set_type</span> = QUIZ
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">sub_module_lessons.intro_video_url</span> ← Intro URL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
|
||||
<Button variant="ghost" onClick={() => navigate(backTo)} className="sm:w-auto">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleNext}>
|
||||
Next: Questions
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{currentStep === 2 ? (
|
||||
<div className="space-y-5">
|
||||
{questions.map((question, index) => (
|
||||
<Card key={question.id} className="border border-grayScale-200/90 border-l-4 border-l-grayScale-700 p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-grayScale-400" />
|
||||
<span className="font-semibold">Question {index + 1}</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeQuestion(question.id)} className="text-grayScale-400 hover:text-red-500">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<PracticeQuestionEditorFields
|
||||
value={{
|
||||
questionText: question.questionText,
|
||||
questionType: question.questionType,
|
||||
difficultyLevel: question.difficultyLevel,
|
||||
points: question.points,
|
||||
tips: question.tips,
|
||||
explanation: question.explanation,
|
||||
options: question.options,
|
||||
voicePrompt: question.voicePrompt,
|
||||
sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
|
||||
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
||||
shortAnswer: question.shortAnswers[0] ?? "",
|
||||
imageUrl: question.imageUrl,
|
||||
}}
|
||||
onChange={(next) =>
|
||||
updateQuestion(question.id, {
|
||||
questionText: next.questionText,
|
||||
questionType: next.questionType as QuestionType,
|
||||
difficultyLevel: next.difficultyLevel as DifficultyLevel,
|
||||
points: next.points,
|
||||
tips: next.tips,
|
||||
explanation: next.explanation,
|
||||
options: next.options,
|
||||
voicePrompt: next.voicePrompt,
|
||||
sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
|
||||
audioCorrectAnswerText: next.audioCorrectAnswerText,
|
||||
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
|
||||
imageUrl: next.imageUrl,
|
||||
})
|
||||
}
|
||||
mediaBusy={saving}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
<Button variant="outline" onClick={addQuestion} className="w-full border-dashed">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add another question
|
||||
</Button>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:px-6 sm:py-5">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext}>
|
||||
Next: Review
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentStep === 3 ? (
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
|
||||
<p className="mt-1.5 text-sm text-grayScale-500">Confirm lesson details and questions before saving or publishing.</p>
|
||||
</div>
|
||||
<div className="space-y-4 p-5 sm:p-8">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3">
|
||||
<h3 className="text-base font-semibold text-grayScale-900">Basic Information</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-brand-500 hover:text-brand-600"
|
||||
onClick={() => setCurrentStep(1)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-grayScale-100">
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<span className="text-grayScale-500">Title</span>
|
||||
<span className="font-medium text-grayScale-800">{lessonTitle || "Untitled Lesson"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<span className="text-grayScale-500">Description</span>
|
||||
<span className="max-w-[55%] truncate text-right font-medium text-grayScale-800">
|
||||
{lessonDescription || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<span className="text-grayScale-500">Intro video URL</span>
|
||||
<span className="max-w-[55%] truncate text-right font-medium text-grayScale-800">{introVideoUrl || "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<span className="text-grayScale-500">Sub-module</span>
|
||||
<span className="font-medium text-grayScale-800">{subModuleId ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3">
|
||||
<h3 className="text-base font-semibold text-grayScale-900">
|
||||
Questions
|
||||
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">
|
||||
{reviewQuestions.length}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-brand-500 hover:text-brand-600"
|
||||
onClick={() => setCurrentStep(2)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 p-3">
|
||||
{reviewQuestions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 p-4 text-sm text-grayScale-500">
|
||||
No question content added yet.
|
||||
</div>
|
||||
) : (
|
||||
reviewQuestions.map((question, idx) => (
|
||||
<div key={question.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/35 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-100 px-1.5 text-[11px] font-semibold text-brand-700">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="rounded-md bg-indigo-50 px-2 py-0.5 text-[11px] font-semibold text-indigo-700">
|
||||
{questionTypeLabel(question.questionType)}
|
||||
</span>
|
||||
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-[11px] font-semibold text-grayScale-600">
|
||||
{question.difficultyLevel}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-grayScale-500">{question.points} pt</span>
|
||||
</div>
|
||||
<p className="mb-2 line-clamp-2 text-sm font-medium text-grayScale-800">
|
||||
{question.questionText.trim() || `Question ${idx + 1}`}
|
||||
</p>
|
||||
{question.questionType === "MCQ" ? (
|
||||
<div className="space-y-1">
|
||||
{question.options.map((option, optionIdx) => (
|
||||
<div
|
||||
key={`${question.id}-option-${optionIdx}`}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
option.isCorrect
|
||||
? "bg-green-50 font-medium text-green-700"
|
||||
: "text-grayScale-500"
|
||||
}`}
|
||||
>
|
||||
{option.text || `Option ${optionIdx + 1}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:px-8 sm:py-5">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => void saveLesson("DRAFT")} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save as Draft"}
|
||||
</Button>
|
||||
<Button onClick={() => void saveLesson("PUBLISHED")} disabled={saving}>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{saving ? "Publishing..." : "Publish Now"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{currentStep === 4 && resultStatus ? (
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center py-16 text-center">
|
||||
<div className={`mb-5 grid h-24 w-24 place-items-center rounded-full ${resultStatus === "success" ? "bg-gradient-to-br from-brand-200 to-brand-400" : "bg-gradient-to-br from-red-200 to-red-400"}`}>
|
||||
<Check className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h2 className="text-4xl font-bold tracking-tight text-grayScale-900">
|
||||
{resultStatus === "success" ? "Lesson Published Successfully!" : "Lesson save failed"}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-grayScale-500">{resultStatus === "success" ? "Your lesson is now active." : resultMessage}</p>
|
||||
<div className="mt-8 w-full space-y-3">
|
||||
<Button
|
||||
className="h-11 w-full text-base"
|
||||
onClick={() =>
|
||||
navigate(lastSavedStatus === "PUBLISHED" ? "/content/human-language" : backTo)
|
||||
}
|
||||
>
|
||||
Go back to Course
|
||||
</Button>
|
||||
{resultStatus === "success" ? (
|
||||
<Button variant="outline" className="h-11 w-full text-base" onClick={() => navigate(0)}>
|
||||
Add Another Lesson
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className="h-11 w-full text-base" onClick={() => setCurrentStep(3)}>
|
||||
Back to Review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
268
src/pages/content-management/AddPracticeFlow.tsx
Normal file
268
src/pages/content-management/AddPracticeFlow.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import successIcon from "../../assets/success.svg";
|
||||
|
||||
import { ContextStep } from "./components/practice-steps/ContextStep";
|
||||
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
||||
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
||||
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
||||
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
||||
|
||||
export function AddPracticeFlow() {
|
||||
const navigate = useNavigate();
|
||||
const { level } = useParams<{ level: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const backTo = searchParams.get("backTo");
|
||||
const courseId = searchParams.get("courseId");
|
||||
const moduleId = searchParams.get("moduleId");
|
||||
|
||||
const isModuleContext = backTo === "module";
|
||||
const isCourseContext = backTo === "modules";
|
||||
|
||||
const backLabel =
|
||||
backTo === "module"
|
||||
? "Back to Module"
|
||||
: backTo === "modules"
|
||||
? "Back to Modules"
|
||||
: "Back to Courses";
|
||||
const backPath =
|
||||
backTo === "module" && courseId && moduleId
|
||||
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`
|
||||
: backTo === "modules" && courseId
|
||||
? `/new-content/learn-english/${level}/courses/${courseId}`
|
||||
: `/new-content/learn-english/${level}/courses`;
|
||||
|
||||
const flowSteps = isModuleContext
|
||||
? ["Context", "Persona", "Questions", "Review"]
|
||||
: ["Context", "Scenario", "Persona", "Questions", "Review"];
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(
|
||||
"dawit",
|
||||
);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
program: "Intermediate",
|
||||
course: "A2",
|
||||
title: "",
|
||||
description: "",
|
||||
selectedVideo: "",
|
||||
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
text: "How long have you been studying English?",
|
||||
type: "Speaking",
|
||||
voicePrompt: "prompt_q1_en.mp3",
|
||||
sampleAnswer: "prompt_q1_en.mp3",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextStep = () =>
|
||||
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500">
|
||||
<div className="mb-10 relative">
|
||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
||||
<img
|
||||
src={successIcon}
|
||||
alt="Success"
|
||||
className="h-[128px] w-[128px] relative"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
|
||||
Practice Published Successfully!
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
||||
Your speaking practice is now active and available inside the module.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||
<Button
|
||||
onClick={() => navigate(backPath)}
|
||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
||||
>
|
||||
Go back to Module
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
setFormData({
|
||||
...formData,
|
||||
title: "",
|
||||
description: "",
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white "
|
||||
>
|
||||
Add Another Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to map currentStep to the actual component for the module flow
|
||||
const renderStep = () => {
|
||||
if (!isModuleContext) {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ContextStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
navigate={navigate}
|
||||
level={level!}
|
||||
isModuleContext={isModuleContext}
|
||||
isCourseContext={isCourseContext}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ScenarioStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<PersonaStep
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<QuestionsStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<ReviewStep
|
||||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
prevStep={prevStep}
|
||||
setIsPublished={setIsPublished}
|
||||
isModuleContext={isModuleContext}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Module Context Flow (Skips Scenario)
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ContextStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
navigate={navigate}
|
||||
level={level!}
|
||||
isModuleContext={isModuleContext}
|
||||
isCourseContext={isCourseContext}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<PersonaStep
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<QuestionsStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<ReviewStep
|
||||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
prevStep={prevStep}
|
||||
setIsPublished={setIsPublished}
|
||||
isModuleContext={isModuleContext}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-7xl w-full">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
to={backPath}
|
||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 decoration-none"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{backLabel}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className=" mb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-[#0F172A]">
|
||||
Add New Practice
|
||||
</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
|
||||
onClick={() => navigate(backPath)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-grayScale-400 text-base">
|
||||
Create a new immersive practice session for students.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-[70%] mb-12">
|
||||
<Stepper steps={flowSteps} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
|
||||
>
|
||||
{renderStep()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/pages/content-management/AddVideoFlow.tsx
Normal file
216
src/pages/content-management/AddVideoFlow.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import { createModuleLesson } from "../../api/courses.api";
|
||||
|
||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
||||
import successIcon from "../../assets/success.svg";
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, label: "Video Detail" },
|
||||
{ id: 2, label: "Review & Publish" },
|
||||
];
|
||||
|
||||
export type AddLessonFormData = {
|
||||
title: string;
|
||||
order: string;
|
||||
description: string;
|
||||
videoUrl: string;
|
||||
thumbnailUrl: string;
|
||||
};
|
||||
|
||||
const emptyForm = (): AddLessonFormData => ({
|
||||
title: "",
|
||||
order: "1",
|
||||
description: "",
|
||||
videoUrl: "",
|
||||
thumbnailUrl: "",
|
||||
});
|
||||
|
||||
function descriptionToApiPlain(html: string): string {
|
||||
if (!html?.trim()) return "";
|
||||
const t = html.trim();
|
||||
if (!t.includes("<")) return t;
|
||||
if (typeof document === "undefined") {
|
||||
return t.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
export function AddVideoFlow() {
|
||||
const navigate = useNavigate();
|
||||
const { level, courseId, moduleId } = useParams<{
|
||||
level: string;
|
||||
courseId: string;
|
||||
moduleId: string;
|
||||
}>();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [formResetKey, setFormResetKey] = useState(0);
|
||||
|
||||
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||
|
||||
const handlePublish = async () => {
|
||||
const mid = Number(moduleId);
|
||||
if (!Number.isFinite(mid) || mid < 1) {
|
||||
toast.error("Invalid module");
|
||||
return;
|
||||
}
|
||||
const title = formData.title.trim();
|
||||
const videoUrl = formData.videoUrl.trim();
|
||||
const thumbnail = formData.thumbnailUrl.trim();
|
||||
if (!title) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
if (!videoUrl) {
|
||||
toast.error("Video URL is required");
|
||||
return;
|
||||
}
|
||||
if (!thumbnail) {
|
||||
toast.error("Thumbnail is required");
|
||||
return;
|
||||
}
|
||||
const description = descriptionToApiPlain(formData.description);
|
||||
if (!description) {
|
||||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
setPublishing(true);
|
||||
try {
|
||||
await createModuleLesson(mid, {
|
||||
title,
|
||||
video_url: videoUrl,
|
||||
thumbnail,
|
||||
description,
|
||||
});
|
||||
toast.success("Lesson created");
|
||||
setIsPublished(true);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create lesson";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500 ">
|
||||
<div className="mb-12 relative scale-110">
|
||||
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
||||
<img
|
||||
src={successIcon}
|
||||
alt="Success"
|
||||
className="h-[128px] w-[128px] relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
||||
Lesson created successfully
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
||||
Your lesson is now available in this module.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||
<Button
|
||||
onClick={() => navigate(backPath)}
|
||||
className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95"
|
||||
>
|
||||
View module
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFormData(emptyForm());
|
||||
setFormResetKey((k) => k + 1);
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
}}
|
||||
variant="outline"
|
||||
className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white"
|
||||
>
|
||||
Add another lesson
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(`/new-content/learn-english/${level}/courses`)}
|
||||
variant="ghost"
|
||||
className="h-10 text-grayScale-600 font-medium"
|
||||
>
|
||||
All courses
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
||||
<div className="mx-auto max-w-7xl w-full">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
to={backPath}
|
||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-500 transition-colors hover:text-brand-500 decoration-none"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to module
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
|
||||
onClick={() => navigate(backPath)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-[#0F172A] mb-10">
|
||||
Add new lesson
|
||||
</h1>
|
||||
|
||||
<div className="mx-auto max-w-4xl mb-12">
|
||||
<Stepper
|
||||
steps={STEPS.map((s) => s.label)}
|
||||
currentStep={currentStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{currentStep === 1 && (
|
||||
<VideoDetailStep
|
||||
key={formResetKey}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onContinue={nextStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<ReviewPublishStep
|
||||
formData={formData}
|
||||
prevStep={prevStep}
|
||||
onPublish={() => void handlePublish()}
|
||||
publishing={publishing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ import {
|
|||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
|
||||
type CourseWithCategory = Course & { category_name: string }
|
||||
|
||||
|
|
@ -230,10 +230,7 @@ export function AllCoursesPage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||
<SpinnerIcon className="h-10 w-10" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories…</p>
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
193
src/pages/content-management/AttachPracticeFlow.tsx
Normal file
193
src/pages/content-management/AttachPracticeFlow.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Clock, FileVideo, Check } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import successIcon from "../../assets/success.svg";
|
||||
|
||||
import { AttachPracticeStep1 } from "./components/practice-steps/AttachPracticeStep1";
|
||||
import { AttachPracticeReviewStep } from "./components/practice-steps/AttachPracticeReviewStep";
|
||||
|
||||
export function AttachPracticeFlow() {
|
||||
const navigate = useNavigate();
|
||||
const { programType, courseId, unitId, moduleId } = useParams<{
|
||||
programType: string;
|
||||
courseId: string;
|
||||
unitId: string;
|
||||
moduleId: string;
|
||||
}>();
|
||||
|
||||
const backPath = `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
program:
|
||||
programType === "skill"
|
||||
? "Skill-Based Courses"
|
||||
: "English Proficiency Exams",
|
||||
module: "Module 4: Interactive Speaking",
|
||||
video: "Intro to Interactive Speaking",
|
||||
questionType: "speaking",
|
||||
version: "v1",
|
||||
});
|
||||
|
||||
const steps = ["Set Video", "Review & Publish"];
|
||||
|
||||
const nextStep = () =>
|
||||
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-700 ">
|
||||
{/* Scalloped Success Icon */}
|
||||
<div className="mb-10 relative">
|
||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
||||
<img
|
||||
src={successIcon}
|
||||
alt="Success"
|
||||
className="h-[128px] w-[128px] relative"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
|
||||
Practice Attached Successfully!
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-md mb-14 max-w-2xl font-medium leading-relaxed">
|
||||
The practice has been successfully linked to a video{" "}
|
||||
<span className="text-[#9E2891]">“{formData.video}”</span>
|
||||
</p>
|
||||
|
||||
{/* Video Info Card */}
|
||||
<div className="w-full max-w-[600px] bg-[#9E289114] border border-[#9E2891] rounded-[12px] p-4 flex items-center justify-between mb-16 shadow-sm">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="h-[60px] w-[120px] rounded-xl overflow-hidden shadow-inner flex-shrink-0">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1557425955-df376b5903c8?auto=format&fit=crop&q=80&w=400"
|
||||
alt="Video Thumbnail"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left space-y-1.5">
|
||||
<h4 className="text-[14px] font-medium text-grayScale-900">
|
||||
Intro to IELTS Speaking Part 1
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-grayScale-400 font-medium text-[12px]">
|
||||
<div className="flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<Clock className="h-3 w-3" />
|
||||
10:42 mins
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-1.5 uppercase tracking-wide">
|
||||
MP4
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pr-4">
|
||||
<Check className="h-5 w-5 text-[#9E2891] stroke-[3px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-4 w-full max-w-[440px]">
|
||||
<Button
|
||||
onClick={() => navigate(backPath)}
|
||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white"
|
||||
>
|
||||
Go back to Videos
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
}}
|
||||
className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white"
|
||||
>
|
||||
Attach More Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<AttachPracticeStep1
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
onCancel={() => navigate(backPath)}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<AttachPracticeReviewStep
|
||||
formData={formData}
|
||||
prevStep={prevStep}
|
||||
onPublish={() => setIsPublished(true)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
currentStep === 1 ? "Attach Practice to a Video" : "Review & Publish";
|
||||
const description =
|
||||
currentStep === 1
|
||||
? "Create a new immersive practice session for a video."
|
||||
: "Verify practice details before publishing it.";
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-32 px-6 pt-10 min-h-screen animate-in fade-in duration-500">
|
||||
<div className="mx-auto w-full">
|
||||
{/* Navigation Breadcrumb */}
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<Link
|
||||
to={backPath}
|
||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Videos
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stepper Area */}
|
||||
<div className="mb-20 w-full pointer-events-none">
|
||||
<Stepper steps={steps} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
{/* Page Title & Header Actions */}
|
||||
<div className="mb-10 flex items-start justify-between">
|
||||
<div className="">
|
||||
<h1 className="text-[30px] font-bold text-[#0D1421] ">{title}</h1>
|
||||
<p className="text-grayScale-400 text-[16px] font-medium ">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-8 rounded-[6px] border-grayScale-100 text-grayScale-600 font-bold bg-white hover:bg-grayScale-50 shadow-sm"
|
||||
onClick={() => navigate(backPath)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-bold text-white shadow-md hover:bg-[#8A237E] transition-all">
|
||||
Save as Draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="w-full">{renderStep()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/pages/content-management/AttachProgramPracticeFlow.tsx
Normal file
157
src/pages/content-management/AttachProgramPracticeFlow.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Check } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import successIcon from "../../assets/success.svg";
|
||||
|
||||
import { ProgramAttachStep1 } from "./components/practice-steps/ProgramAttachStep1";
|
||||
import { ProgramAttachReviewStep } from "./components/practice-steps/ProgramAttachReviewStep";
|
||||
|
||||
export function AttachProgramPracticeFlow() {
|
||||
const navigate = useNavigate();
|
||||
const { programType } = useParams<{ programType: string }>();
|
||||
|
||||
const backPath = `/new-content/courses/${programType}`;
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
program: "English Proficiency Exams",
|
||||
test: "Mock Exam 1",
|
||||
questionType: "Speaking Practice",
|
||||
version: "V 1.0",
|
||||
});
|
||||
|
||||
const steps = ["Set Program", "Review & Publish"];
|
||||
|
||||
const nextStep = () =>
|
||||
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-700 bg-white">
|
||||
{/* Scalloped Success Icon */}
|
||||
<div className="mb-10 relative">
|
||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
||||
<img
|
||||
src={successIcon}
|
||||
alt="Success"
|
||||
className="h-[128px] w-[128px] relative"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
|
||||
Practice Attached Successfully!
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
||||
The practice has been successfully linked to the program{" "}
|
||||
<span className="text-[#9E2891]">“{formData.program}”</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-[440px]">
|
||||
<Button
|
||||
onClick={() => navigate(backPath)}
|
||||
className="h-14 rounded-[12px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
||||
>
|
||||
Go back to Program
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
}}
|
||||
className="h-14 rounded-[12px] border-[#9E2891] text-[#9E2891] font-bold text-[16px] bg-white "
|
||||
>
|
||||
Attach More Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ProgramAttachStep1
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
onCancel={() => navigate(backPath)}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ProgramAttachReviewStep
|
||||
formData={formData}
|
||||
prevStep={prevStep}
|
||||
onPublish={() => setIsPublished(true)}
|
||||
onCancel={() => navigate(backPath)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
currentStep === 1 ? "Attach Practice to a program" : "Review & Publish";
|
||||
const description =
|
||||
currentStep === 1
|
||||
? "Create a new immersive practice session for a video."
|
||||
: "Verify practice details before publishing it.";
|
||||
|
||||
return (
|
||||
<div className="space-y-8 px-6 pt-10 min-h-screen animate-in fade-in duration-500">
|
||||
<div className=" w-full">
|
||||
{/* Navigation Breadcrumb */}
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<Link
|
||||
to={backPath}
|
||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Program
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stepper Area */}
|
||||
<div className="mb-20 pointer-events-none">
|
||||
<Stepper steps={steps} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
{/* Page Title & Header Actions */}
|
||||
<div className="mb-10 flex items-start justify-between">
|
||||
<div className="">
|
||||
<h1 className="text-[30px] font-bold text-[#0D1421] ">{title}</h1>
|
||||
<p className="text-grayScale-500 text-[14px]">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-8 rounded-[6px] border-grayScale-100 text-grayScale-600 font-bold bg-white hover:bg-grayScale-50 shadow-sm"
|
||||
onClick={() => navigate(backPath)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-bold text-white shadow-md hover:bg-[#8A237E] transition-all">
|
||||
Save as Draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div
|
||||
className={`w-full mx-auto ${
|
||||
currentStep === 1 ? "max-w-4xl" : "max-w-none"
|
||||
}`}
|
||||
>
|
||||
{renderStep()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,8 @@
|
|||
import { NavLink, Outlet } from "react-router-dom"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const tabs = [
|
||||
{ label: "Overview", to: "/content" },
|
||||
{ label: "Courses", to: "/content/courses" },
|
||||
{ label: "Human Language", to: "/content/human-language" },
|
||||
{ label: "Flows", to: "/content/flows" },
|
||||
{ label: "Practice", to: "/content/practices" },
|
||||
{ label: "Questions", to: "/content/questions" },
|
||||
]
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export function ContentManagementLayout() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||
|
|
@ -22,38 +11,12 @@ export function ContentManagementLayout() {
|
|||
Content Management
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
|
||||
Manage courses, speaking exercises, practices, and questions
|
||||
View and manage practice content for courses, modules, and lessons
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="scroll-hide mb-8 flex items-center gap-1 overflow-x-auto rounded-2xl border border-grayScale-100 bg-grayScale-50/60 p-1.5 shadow-sm backdrop-blur"
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
<style>{`.scroll-hide::-webkit-scrollbar { display: none; }`}</style>
|
||||
{tabs.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.to === "/content"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"relative whitespace-nowrap rounded-xl px-5 py-2 text-sm font-semibold transition-all duration-200 ease-in-out",
|
||||
"text-grayScale-500 hover:bg-white/80 hover:text-brand-600 hover:shadow-sm",
|
||||
isActive &&
|
||||
"bg-brand-500 text-white shadow-md shadow-brand-500/25 hover:bg-brand-600 hover:text-white",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
|
||||
import { FolderOpen, RefreshCw, BookOpen, Plus, Trash2 } from "lucide-react"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import alertSrc from "../../assets/Alert.svg"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
|
|
@ -11,10 +11,11 @@ import {
|
|||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog"
|
||||
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
|
||||
import { getCourseCategories, createCourseCategory, deleteCourseCategory } from "../../api/courses.api"
|
||||
import type { CourseCategory } from "../../types/course.types"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -29,6 +30,8 @@ export function CourseCategoryPage() {
|
|||
const [newSubCategoryName, setNewSubCategoryName] = useState("")
|
||||
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [deleteTarget, setDeleteTarget] = useState<CourseCategory | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -164,12 +167,26 @@ export function CourseCategoryPage() {
|
|||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||
View Sub-categories
|
||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||
→
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||
View Sub-categories
|
||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-red-200 bg-white text-red-500 hover:bg-red-50"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDeleteTarget(category)
|
||||
}}
|
||||
aria-label={`Delete category ${category.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
|
@ -335,7 +352,7 @@ export function CourseCategoryPage() {
|
|||
if (createdCategoryId && pendingSubCategories.length > 0) {
|
||||
await Promise.all(
|
||||
pendingSubCategories.map((subName) =>
|
||||
createCourseCategory({ name: subName }),
|
||||
createCourseCategory({ name: subName, parent_id: createdCategoryId }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -371,6 +388,46 @@ export function CourseCategoryPage() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete category?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget
|
||||
? `This will permanently delete "${deleteTarget.name}" and all linked sub-categories/courses.`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteCourseCategory(deleteTarget.id)
|
||||
toast.success("Category deleted")
|
||||
setDeleteTarget(null)
|
||||
await fetchCategories()
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.message || "Failed to delete category."
|
||||
toast.error("Could not delete category", { description: message })
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
616
src/pages/content-management/CourseDetailPage.tsx
Normal file
616
src/pages/content-management/CourseDetailPage.tsx
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Calendar,
|
||||
Layers,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { cn } from "../../lib/utils";
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||
import alertSrc from "../../assets/Alert.svg";
|
||||
import {
|
||||
deleteTopLevelCourseModule,
|
||||
getProgramCourses,
|
||||
getTopLevelCourseModules,
|
||||
updateTopLevelCourseModule,
|
||||
} from "../../api/courses.api";
|
||||
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
|
||||
import type {
|
||||
ProgramCourseListItem,
|
||||
TopLevelCourseModuleItem,
|
||||
} from "../../types/course.types";
|
||||
import { AddModuleModal } from "./components/AddModuleModal";
|
||||
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
||||
|
||||
const MODULE_CARD_GRADIENT =
|
||||
"from-[#8E44AD] to-[#C39BD3]" as const;
|
||||
|
||||
function isLikelyImageUrl(src: string): boolean {
|
||||
const t = src.trim();
|
||||
return (
|
||||
t.startsWith("http://") ||
|
||||
t.startsWith("https://") ||
|
||||
t.startsWith("/") ||
|
||||
t.startsWith("data:")
|
||||
);
|
||||
}
|
||||
|
||||
function isSignedMinioUrl(src: string): boolean {
|
||||
const value = src.trim();
|
||||
if (!value.startsWith("http://") && !value.startsWith("https://")) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.searchParams.has("X-Amz-Signature");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default purple gradient with optional cover image; gradient stays if URL missing or image errors. */
|
||||
function ModuleCardTopMedia({ iconSrc }: { iconSrc: string }) {
|
||||
const [coverFailed, setCoverFailed] = useState(false);
|
||||
const tryCover =
|
||||
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !coverFailed;
|
||||
|
||||
return (
|
||||
<div className="relative h-36 w-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-b opacity-90 transition-transform duration-700",
|
||||
MODULE_CARD_GRADIENT,
|
||||
)}
|
||||
/>
|
||||
{tryCover ? (
|
||||
<img
|
||||
src={iconSrc.trim()}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onError={() => setCoverFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circular module icon: image when load succeeds, otherwise default Layers icon. */
|
||||
function ModuleIconCircle({
|
||||
iconSrc,
|
||||
index,
|
||||
}: {
|
||||
iconSrc: string;
|
||||
index: number;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const showImg =
|
||||
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !imgFailed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-purple-100/50 p-2",
|
||||
index % 2 === 1 ? "bg-[#F8FAFC]" : "bg-[#f3e8ff]",
|
||||
)}
|
||||
>
|
||||
{showImg ? (
|
||||
<img
|
||||
src={iconSrc.trim()}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Layers
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
index % 2 === 1 ? "text-[#64748B]" : "text-brand-500",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { level: programIdParam, courseId: courseIdParam } = useParams<{
|
||||
level: string;
|
||||
courseId: string;
|
||||
}>();
|
||||
const programId = Number(programIdParam);
|
||||
const courseIdNum = Number(courseIdParam);
|
||||
|
||||
const [course, setCourse] = useState<ProgramCourseListItem | null>(null);
|
||||
const [modules, setModules] = useState<TopLevelCourseModuleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAddModuleOpen, setIsAddModuleOpen] = useState(false);
|
||||
|
||||
const [editingModule, setEditingModule] =
|
||||
useState<TopLevelCourseModuleItem | null>(null);
|
||||
const [editModuleName, setEditModuleName] = useState("");
|
||||
const [editModuleDescription, setEditModuleDescription] = useState("");
|
||||
const [editModuleIcon, setEditModuleIcon] = useState("");
|
||||
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
||||
useState(false);
|
||||
const [savingModuleEdit, setSavingModuleEdit] = useState(false);
|
||||
|
||||
const [deletingModule, setDeletingModule] =
|
||||
useState<TopLevelCourseModuleItem | null>(null);
|
||||
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
|
||||
|
||||
const openEditModule = (module: TopLevelCourseModuleItem) => {
|
||||
setEditingModule(module);
|
||||
setEditModuleName(module.name ?? "");
|
||||
setEditModuleDescription(module.description ?? "");
|
||||
setEditModuleIcon(module.icon?.trim() ?? "");
|
||||
setEditModuleIconUploadBusy(false);
|
||||
};
|
||||
|
||||
const closeEditModule = () => {
|
||||
if (savingModuleEdit || editModuleIconUploadBusy) return;
|
||||
setEditingModule(null);
|
||||
setEditModuleIconUploadBusy(false);
|
||||
};
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
if (!Number.isFinite(programId) || programId < 1) {
|
||||
setError("Invalid program");
|
||||
setCourse(null);
|
||||
setModules([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
|
||||
setError("Invalid course");
|
||||
setCourse(null);
|
||||
setModules([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [courseOutcome, modulesOutcome] = await Promise.allSettled([
|
||||
getProgramCourses(programId, { limit: 200, offset: 0 }),
|
||||
getTopLevelCourseModules(courseIdNum, { limit: 100, offset: 0 }),
|
||||
]);
|
||||
|
||||
if (courseOutcome.status === "fulfilled") {
|
||||
const raw = courseOutcome.value.data?.data?.courses;
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
const found = list.find((c) => c.id === courseIdNum) ?? null;
|
||||
setCourse(found);
|
||||
if (!found) {
|
||||
setError("Course not found in this program");
|
||||
}
|
||||
} else {
|
||||
console.error(courseOutcome.reason);
|
||||
setCourse(null);
|
||||
setError("Failed to load course");
|
||||
}
|
||||
|
||||
if (modulesOutcome.status === "fulfilled") {
|
||||
const raw = modulesOutcome.value.data?.data?.modules;
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
const refreshed = await Promise.all(
|
||||
list.map(async (module) => {
|
||||
const icon = module.icon?.trim() ?? "";
|
||||
if (!icon) return module;
|
||||
try {
|
||||
if (isSignedMinioUrl(icon)) {
|
||||
const refreshedRes = await refreshFileUrl(icon);
|
||||
const refreshedUrl = refreshedRes.data?.data?.url?.trim();
|
||||
if (refreshedUrl) {
|
||||
return { ...module, icon: refreshedUrl };
|
||||
}
|
||||
return module;
|
||||
}
|
||||
if (isLikelyImageUrl(icon)) return module;
|
||||
const resolved = await resolveFileUrl(icon);
|
||||
const freshUrl = resolved.data?.data?.url?.trim();
|
||||
if (!freshUrl) return module;
|
||||
return { ...module, icon: freshUrl };
|
||||
} catch {
|
||||
return module;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const sorted = [...refreshed].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
);
|
||||
setModules(sorted);
|
||||
} else {
|
||||
console.error(modulesOutcome.reason);
|
||||
setModules([]);
|
||||
toast.error("Could not load modules", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Failed to load course");
|
||||
setCourse(null);
|
||||
setModules([]);
|
||||
toast.error("Could not load course", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [programId, courseIdNum]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
const handleSaveModuleEdit = async () => {
|
||||
if (!editingModule) return;
|
||||
const name = editModuleName.trim();
|
||||
if (!name) {
|
||||
toast.error("Module name is required");
|
||||
return;
|
||||
}
|
||||
setSavingModuleEdit(true);
|
||||
try {
|
||||
await updateTopLevelCourseModule(editingModule.id, {
|
||||
name,
|
||||
description: editModuleDescription.trim(),
|
||||
icon: editModuleIcon.trim(),
|
||||
});
|
||||
toast.success("Module updated");
|
||||
setEditModuleIconUploadBusy(false);
|
||||
setEditingModule(null);
|
||||
await loadPage();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update module";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingModuleEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteModule = async () => {
|
||||
if (!deletingModule) return;
|
||||
setDeletingModuleInFlight(true);
|
||||
try {
|
||||
await deleteTopLevelCourseModule(deletingModule.id);
|
||||
toast.success("Module deleted");
|
||||
setDeletingModule(null);
|
||||
await loadPage();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete module";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeletingModuleInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayTitle =
|
||||
course?.name?.trim() || courseIdParam || "Course";
|
||||
const displayDescription =
|
||||
course?.description?.trim() ||
|
||||
(!loading && !course
|
||||
? "This course could not be loaded."
|
||||
: !course?.description?.trim() && course
|
||||
? "—"
|
||||
: "");
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-20 pt-10">
|
||||
{/* Header Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/new-content/learn-english/${programIdParam}/courses`}
|
||||
className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Back to Courses
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
) : error && !course ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => void loadPage()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<AddModuleModal
|
||||
isOpen={isAddModuleOpen}
|
||||
onClose={() => setIsAddModuleOpen(false)}
|
||||
/>
|
||||
{/* Gradient Divider */}
|
||||
|
||||
{/* Gradient Grid */}
|
||||
<div className="flex flex-warp gap-10">
|
||||
{MODULES.map((module) => (
|
||||
<Card
|
||||
key={module.id}
|
||||
className="group overflow-hidden border w-[330px] border-grayScale-50 shadow-sm hover:shadow-lg transition-all duration-300 rounded-[16px] bg-white flex flex-col h-full"
|
||||
>
|
||||
{/* Gradient Banner */}
|
||||
<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
|
||||
isOpen={isAddModuleOpen}
|
||||
onClose={() => setIsAddModuleOpen(false)}
|
||||
courseId={courseIdNum}
|
||||
onCreated={() => loadPage()}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editingModule !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && savingModuleEdit) return;
|
||||
if (!open && editModuleIconUploadBusy) return;
|
||||
if (!open) closeEditModule();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and icon (upload or URL). Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /modules/:id
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={editModuleName}
|
||||
onChange={(e) => setEditModuleName(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="e.g. Grammar basics"
|
||||
disabled={savingModuleEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={editModuleDescription}
|
||||
onChange={(e) => setEditModuleDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[100px] resize-y rounded-xl"
|
||||
placeholder="Optional short description."
|
||||
disabled={savingModuleEdit}
|
||||
/>
|
||||
</div>
|
||||
<ModuleIconUploadField
|
||||
value={editModuleIcon}
|
||||
onChange={setEditModuleIcon}
|
||||
disabled={savingModuleEdit}
|
||||
onUploadBusyChange={setEditModuleIconUploadBusy}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEditModule}
|
||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
||||
onClick={() => void handleSaveModuleEdit()}
|
||||
>
|
||||
{savingModuleEdit ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{modules.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No modules in this course yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Add modules when your workflow is connected, or create them via
|
||||
the API.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid justify-start gap-10"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
|
||||
}}
|
||||
>
|
||||
{modules.map((module, index) => {
|
||||
const iconSrc = module.icon?.trim() ?? "";
|
||||
return (
|
||||
<Card
|
||||
key={module.id}
|
||||
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div
|
||||
className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${module.name}`}
|
||||
onClick={() => openEditModule(module)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${module.name}`}
|
||||
onClick={() => setDeletingModule(module)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<ModuleCardTopMedia iconSrc={iconSrc} />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<ModuleIconCircle iconSrc={iconSrc} index={index} />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
|
||||
{module.name}
|
||||
</h3>
|
||||
<p className="text-[12px] font-medium leading-snug text-grayScale-400">
|
||||
{module.description?.trim() ? module.description : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex shrink-0 items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
|
||||
{
|
||||
state: {
|
||||
moduleName: module.name,
|
||||
moduleDescription: module.description?.trim() ?? "",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
View Detail
|
||||
</Button>
|
||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
|
||||
Publish Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deletingModule && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||
<h2 className="text-lg font-bold text-grayScale-700">
|
||||
Delete module
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
!deletingModuleInFlight && setDeletingModule(null)
|
||||
}
|
||||
disabled={deletingModuleInFlight}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-700">
|
||||
{deletingModule.name}
|
||||
</span>
|
||||
? This cannot be undone. Related content may be affected
|
||||
depending on your backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div 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={() => setDeletingModule(null)}
|
||||
disabled={deletingModuleInFlight}
|
||||
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={deletingModuleInFlight}
|
||||
onClick={() => void handleConfirmDeleteModule()}
|
||||
>
|
||||
{deletingModuleInFlight ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
|
|
@ -32,9 +31,9 @@ import { Badge } from "../../components/ui/badge"
|
|||
import {
|
||||
getCourseCategories,
|
||||
getCoursesByCategory,
|
||||
getLearningPath,
|
||||
getSubModulesByCourse,
|
||||
getVideosBySubModule,
|
||||
getQuestionSetsByOwner,
|
||||
getSubModuleEntryAssessment,
|
||||
reorderCategories,
|
||||
reorderCourses,
|
||||
reorderSubModules,
|
||||
|
|
@ -194,9 +193,7 @@ export function CourseFlowBuilderPage() {
|
|||
const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>(
|
||||
{},
|
||||
)
|
||||
const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState<Record<number, QuestionSet | null>>(
|
||||
{},
|
||||
)
|
||||
const [videosBySubCourse, setVideosBySubCourse] = useState<Record<number, LearningPathVideo[]>>({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingCourses, setLoadingCourses] = useState(false)
|
||||
|
|
@ -260,7 +257,9 @@ export function CourseFlowBuilderPage() {
|
|||
setLoadingCourses(true)
|
||||
try {
|
||||
const res = await getCoursesByCategory(selectedCategoryId)
|
||||
const items = sortByDisplayOrder(res.data.data.courses ?? [])
|
||||
const items = sortByDisplayOrder(
|
||||
(res.data.data.courses ?? []).filter((course) => Number(course.category_id) === Number(selectedCategoryId)),
|
||||
)
|
||||
setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items }))
|
||||
setSelectedCourseId(items[0]?.id ?? null)
|
||||
} catch {
|
||||
|
|
@ -280,47 +279,94 @@ export function CourseFlowBuilderPage() {
|
|||
const load = async () => {
|
||||
setLoadingPath(true)
|
||||
try {
|
||||
const res = await getLearningPath(selectedCourseId)
|
||||
const path = res.data.data
|
||||
const selectedCourse = activeCourses.find((course) => course.id === selectedCourseId)
|
||||
const subRes = await getSubModulesByCourse(selectedCourseId)
|
||||
const subCourses = sortByDisplayOrder((subRes.data.data.sub_courses ?? []) as any[]).map((sc) => ({
|
||||
id: sc.id,
|
||||
title: sc.title,
|
||||
description: sc.description ?? "",
|
||||
thumbnail: sc.thumbnail ?? "",
|
||||
display_order: sc.display_order ?? 0,
|
||||
level: sc.level ?? sc.cefr_level ?? "",
|
||||
sub_level: sc.sub_level ?? "",
|
||||
prerequisite_count: 0,
|
||||
video_count: 0,
|
||||
practice_count: 0,
|
||||
prerequisites: [],
|
||||
videos: [],
|
||||
practices: [],
|
||||
}))
|
||||
|
||||
setLearningPath({
|
||||
...path,
|
||||
sub_courses: sortByDisplayOrder(path.sub_courses ?? []),
|
||||
course_id: selectedCourseId,
|
||||
course_title: selectedCourse?.title ?? "",
|
||||
description: selectedCourse?.description ?? "",
|
||||
thumbnail: selectedCourse?.thumbnail ?? "",
|
||||
intro_video_url: "",
|
||||
category_id: selectedCategoryId ?? 0,
|
||||
category_name: topLevelCategories.find((cat) => cat.id === selectedCategoryId)?.name ?? "",
|
||||
sub_courses: subCourses,
|
||||
})
|
||||
|
||||
// Practices source of truth: question sets by SUB_COURSE owner.
|
||||
const subCourses = path.sub_courses ?? []
|
||||
if (subCourses.length > 0) {
|
||||
const ownerResults = await Promise.all(
|
||||
if (subCourses.length === 0) {
|
||||
setPracticesBySubCourse({})
|
||||
setVideosBySubCourse({})
|
||||
return
|
||||
}
|
||||
|
||||
const [ownerResults, videoResults] = await Promise.all([
|
||||
Promise.all(
|
||||
subCourses.map(async (sc) => {
|
||||
const setsRes = await getQuestionSetsByOwner("SUB_COURSE", sc.id)
|
||||
const setsRes = await getQuestionSetsByOwner("SUB_MODULE", sc.id)
|
||||
return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
|
||||
}),
|
||||
)
|
||||
const practiceMap: Record<number, PracticeListItem[]> = {}
|
||||
ownerResults.forEach(([subCourseId, practiceItems]) => {
|
||||
practiceMap[subCourseId] = practiceItems
|
||||
})
|
||||
setPracticesBySubCourse(practiceMap)
|
||||
} else {
|
||||
setPracticesBySubCourse({})
|
||||
}
|
||||
),
|
||||
Promise.all(
|
||||
subCourses.map(async (sc) => {
|
||||
const videosRes = await getVideosBySubModule(sc.id)
|
||||
const rows = videosRes.data?.data?.videos ?? []
|
||||
const mapped = sortByDisplayOrder(
|
||||
rows.map((video: any, idx: number) => ({
|
||||
id: Number(video.id),
|
||||
title: String(video.title ?? "Video"),
|
||||
display_order: Number(video.display_order ?? idx),
|
||||
duration: Number(video.duration ?? 0),
|
||||
video_url: String(video.video_url ?? ""),
|
||||
})),
|
||||
)
|
||||
return [sc.id, mapped] as const
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
const practiceMap: Record<number, PracticeListItem[]> = {}
|
||||
ownerResults.forEach(([subCourseId, practiceItems]) => {
|
||||
practiceMap[subCourseId] = practiceItems
|
||||
})
|
||||
setPracticesBySubCourse(practiceMap)
|
||||
|
||||
const videoMap: Record<number, LearningPathVideo[]> = {}
|
||||
videoResults.forEach(([subCourseId, videos]) => {
|
||||
videoMap[subCourseId] = videos
|
||||
})
|
||||
setVideosBySubCourse(videoMap)
|
||||
} catch {
|
||||
toast.error("Failed to load course sub-category learning path.")
|
||||
toast.error("Failed to load course flow detail.")
|
||||
setLearningPath(null)
|
||||
} finally {
|
||||
setLoadingPath(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [selectedCourseId])
|
||||
}, [selectedCourseId, activeCourses, selectedCategoryId, topLevelCategories])
|
||||
|
||||
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
|
||||
if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return
|
||||
if (practicesBySubCourse[subCourseId] && videosBySubCourse[subCourseId]) return
|
||||
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
|
||||
try {
|
||||
const [setsRes, entryRes] = await Promise.allSettled([
|
||||
getQuestionSetsByOwner("SUB_COURSE", subCourseId),
|
||||
getSubModuleEntryAssessment(subCourseId),
|
||||
const [setsRes, videosRes] = await Promise.allSettled([
|
||||
getQuestionSetsByOwner("SUB_MODULE", subCourseId),
|
||||
getVideosBySubModule(subCourseId),
|
||||
])
|
||||
|
||||
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
|
||||
|
|
@ -339,20 +385,21 @@ export function CourseFlowBuilderPage() {
|
|||
[subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
|
||||
}))
|
||||
|
||||
// Entry assessment may legitimately be absent.
|
||||
let entryAssessment: QuestionSet | null = null
|
||||
if (entryRes.status === "fulfilled") {
|
||||
entryAssessment = (entryRes.value.data.data ?? null) as QuestionSet | null
|
||||
} else {
|
||||
const status = entryRes.reason?.response?.status
|
||||
if (status !== 404) {
|
||||
throw entryRes.reason
|
||||
}
|
||||
}
|
||||
|
||||
setEntryAssessmentBySubCourse((prev) => ({
|
||||
const videos =
|
||||
videosRes.status === "fulfilled"
|
||||
? sortByDisplayOrder(
|
||||
(videosRes.value.data?.data?.videos ?? []).map((video: any, idx: number) => ({
|
||||
id: Number(video.id),
|
||||
title: String(video.title ?? "Video"),
|
||||
display_order: Number(video.display_order ?? idx),
|
||||
duration: Number(video.duration ?? 0),
|
||||
video_url: String(video.video_url ?? ""),
|
||||
})),
|
||||
)
|
||||
: []
|
||||
setVideosBySubCourse((prev) => ({
|
||||
...prev,
|
||||
[subCourseId]: entryAssessment,
|
||||
[subCourseId]: videos,
|
||||
}))
|
||||
} catch {
|
||||
toast.error("Failed to load practice sets for course.")
|
||||
|
|
@ -694,6 +741,7 @@ export function CourseFlowBuilderPage() {
|
|||
{learningPath.sub_courses.map((subCourse) => {
|
||||
const expanded = expandedSubCourseIds.has(subCourse.id)
|
||||
const practices = practicesBySubCourse[subCourse.id] ?? []
|
||||
const videos = videosBySubCourse[subCourse.id] ?? subCourse.videos ?? []
|
||||
return (
|
||||
<SortableRow key={subCourse.id} id={subCourse.id}>
|
||||
<button
|
||||
|
|
@ -723,17 +771,12 @@ export function CourseFlowBuilderPage() {
|
|||
{subCourse.sub_level}
|
||||
</Badge>
|
||||
)}
|
||||
{entryAssessmentBySubCourse[subCourse.id] && (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
Entry assessment
|
||||
</span>
|
||||
)}
|
||||
{/* entry-assessment route is no longer guaranteed across deployments */}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices
|
||||
{videos.length} videos / {practices.length} practices
|
||||
</Badge>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-grayScale-400" />
|
||||
|
|
@ -755,16 +798,16 @@ export function CourseFlowBuilderPage() {
|
|||
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
|
||||
>
|
||||
<SortableContext
|
||||
items={subCourse.videos.map((item) => item.id)}
|
||||
items={videos.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
{subCourse.videos.length === 0 ? (
|
||||
{videos.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400">
|
||||
No videos
|
||||
</p>
|
||||
) : (
|
||||
subCourse.videos.map((video) => (
|
||||
videos.map((video) => (
|
||||
<SortableChip
|
||||
key={video.id}
|
||||
id={video.id}
|
||||
|
|
@ -842,7 +885,7 @@ export function CourseFlowBuilderPage() {
|
|||
</p>
|
||||
<p>
|
||||
Practices load from <code>/question-sets/by-owner</code> filtered by
|
||||
<code> set_type=PRACTICE</code>; entry assessment loads from dedicated course endpoint.
|
||||
<code> set_type=PRACTICE</code> and <code>owner_type=SUB_MODULE</code>.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
264
src/pages/content-management/CourseManagementPage.tsx
Normal file
264
src/pages/content-management/CourseManagementPage.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
PlayCircle,
|
||||
ClipboardCheck,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
|
||||
export function CourseManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
const { programType, courseId } = useParams<{
|
||||
programType: string;
|
||||
courseId: string;
|
||||
}>();
|
||||
|
||||
// Mock data for display titles
|
||||
const courseTitles: Record<string, string> = {
|
||||
duolingo: "Duolingo English Test",
|
||||
ielts: "IELTS Academic",
|
||||
};
|
||||
|
||||
const courseDisplayName =
|
||||
courseTitles[courseId || ""] || "Duolingo English Test";
|
||||
|
||||
const units = [
|
||||
{
|
||||
id: "unit1",
|
||||
name: "Greetings & Introductions",
|
||||
description:
|
||||
"Learn basic greetings, self-introductions, and polite expressions in everyday situations.",
|
||||
modules: 3,
|
||||
videos: 9,
|
||||
practices: 9,
|
||||
gradient:
|
||||
"linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
|
||||
},
|
||||
{
|
||||
id: "unit2",
|
||||
name: "Speaking",
|
||||
description:
|
||||
"Core speaking practice and skill building for natural pronunciation and fluency.",
|
||||
modules: 3,
|
||||
videos: 9,
|
||||
practices: 9,
|
||||
gradient:
|
||||
"linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)",
|
||||
},
|
||||
{
|
||||
id: "unit3",
|
||||
name: "Reading",
|
||||
description:
|
||||
"Reading comprehension and vocabulary improvement through various text types.",
|
||||
modules: 3,
|
||||
videos: 9,
|
||||
practices: 9,
|
||||
gradient:
|
||||
"linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||
{/* Navigation */}
|
||||
<Link
|
||||
to={`/new-content/courses/${programType}`}
|
||||
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Courses
|
||||
</Link>
|
||||
|
||||
{/* Header section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
|
||||
{courseDisplayName}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[15px] font-medium leading-relaxed text-grayScale-500">
|
||||
Manage units and modules inside the {courseDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Dialog>
|
||||
<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">
|
||||
<Plus className="h-5 w-5" />
|
||||
Add Unit
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
||||
<div className="bg-white">
|
||||
<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">
|
||||
Create Courses
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Unit Name
|
||||
</label>
|
||||
<Input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Thumbnail
|
||||
</label>
|
||||
<div className="relative group cursor-pointer">
|
||||
<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">
|
||||
Click to upload
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or drag and drop
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
||||
Create Courses
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
|
||||
onClick={() =>
|
||||
navigate(`/new-content/courses/${programType}/attach-practice`)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid of Units */}
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
{units.map((unit) => (
|
||||
<Card
|
||||
key={unit.id}
|
||||
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"
|
||||
>
|
||||
{/* Gradient Header */}
|
||||
<div
|
||||
className="h-36 w-full transition-transform duration-500 "
|
||||
style={{ background: unit.gradient }}
|
||||
/>
|
||||
|
||||
<div className="p-4 flex flex-col flex-1 space-y-6">
|
||||
<div className="space-y-3 flex-1">
|
||||
<h3 className="text-[18px] font-medium text-grayScale-900 transition-colors">
|
||||
{unit.name}
|
||||
</h3>
|
||||
<p className="text-[12px] text-grayScale-500 font-medium line-clamp-3">
|
||||
{unit.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Pills */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
||||
<LayoutGrid className="h-3.5 w-3.5 text-grayScale-400" />
|
||||
<span className="text-[12px] font-bold">
|
||||
{unit.modules} Modules
|
||||
</span>
|
||||
</div>
|
||||
<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" />
|
||||
<span className="text-[12px] font-bold">
|
||||
{unit.videos} Videos
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
||||
<ClipboardCheck className="h-3.5 w-3.5 text-grayScale-400" />
|
||||
<span className="text-[12px] font-bold">
|
||||
{unit.practices} Practices
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
className="w-full h-10 bg-brand-500 text-white rounded-[6px] font-bold flex items-center justify-center gap-2 group/btn"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/courses/${programType}/${courseId}/${unit.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View Detail
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
src/pages/content-management/CourseModuleDetailPage.tsx
Normal file
248
src/pages/content-management/CourseModuleDetailPage.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { useState } from "react";
|
||||
import { ArrowLeft, Plus, FileText, MoreVertical, Edit2 } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Card } from "../../components/ui/card";
|
||||
|
||||
const MOCK_VIDEOS = [
|
||||
{
|
||||
id: "v1",
|
||||
title: "1.1 Introduction to Formal Greetings",
|
||||
duration: "08:45",
|
||||
status: "Draft",
|
||||
thumbnailColor: "bg-[#CBD5E1]",
|
||||
},
|
||||
{
|
||||
id: "v2",
|
||||
title: "1.2 Understanding Email Structure",
|
||||
duration: "08:45",
|
||||
status: "Published",
|
||||
thumbnailColor: "bg-[#DBEAFE]",
|
||||
},
|
||||
{
|
||||
id: "v3",
|
||||
title: "1.3 Common Business Idioms",
|
||||
duration: "08:45",
|
||||
status: "Published",
|
||||
thumbnailColor: "bg-[#FEF3C7]",
|
||||
},
|
||||
{
|
||||
id: "v4",
|
||||
title: "1.4 Video Conference Etiquette",
|
||||
duration: "08:45",
|
||||
status: "Published",
|
||||
thumbnailColor: "bg-[#FCE7F3]",
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_PRACTICES = [
|
||||
{
|
||||
id: "p1",
|
||||
title: "1.1 Conversation Practice",
|
||||
duration: "08:45",
|
||||
status: "Published",
|
||||
thumbnailColor: "bg-[#E0F2FE]",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
title: "1.2 Roleplay Scenario",
|
||||
duration: "08:45",
|
||||
status: "Draft",
|
||||
thumbnailColor: "bg-[#F0FDF4]",
|
||||
},
|
||||
];
|
||||
|
||||
export function CourseModuleDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { programType, courseId, unitId, moduleId } = useParams<{
|
||||
programType: string;
|
||||
courseId: string;
|
||||
unitId: string;
|
||||
moduleId: string;
|
||||
}>();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||
const [activeFilter, setActiveFilter] = useState("All");
|
||||
|
||||
const moduleTitle = "Module 1: Basic Phrases";
|
||||
const moduleDescription = "Learn essential phrases for daily conversations.";
|
||||
|
||||
const content = activeTab === "video" ? MOCK_VIDEOS : MOCK_PRACTICES;
|
||||
const filteredContent = content.filter((item) => {
|
||||
if (activeFilter === "All") return true;
|
||||
if (activeFilter === "Drafts") return item.status === "Draft";
|
||||
return item.status === activeFilter;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||
{/* Navigation */}
|
||||
<Link
|
||||
to={`/new-content/courses/${programType}/${courseId}/${unitId}`}
|
||||
className="flex items-center gap-2.5 text-[15px] font-bold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Modules
|
||||
</Link>
|
||||
|
||||
{/* Header section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
|
||||
{moduleTitle}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[16px] font-medium leading-relaxed text-grayScale-400">
|
||||
{moduleDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
</Button>
|
||||
<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 Video
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-10 border-b border-grayScale-100">
|
||||
<button
|
||||
onClick={() => setActiveTab("video")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
||||
activeTab === "video"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Video
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("practice")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
||||
activeTab === "practice"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Practice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white border border-grayScale-100 rounded-[16px] p-4 flex items-center gap-8 shadow-sm">
|
||||
<div className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest pl-4">
|
||||
STATUS:
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{["All", "Published", "Drafts", "Archived"].map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setActiveFilter(filter)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-[13px] font-bold transition-all",
|
||||
activeFilter === filter
|
||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
||||
: "bg-grayScale-100 text-grayScale-500 hover:bg-grayScale-200",
|
||||
)}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid of Content */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
||||
{filteredContent.map((item) => (
|
||||
<ContentCard key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentCard({
|
||||
title,
|
||||
duration,
|
||||
status,
|
||||
thumbnailColor,
|
||||
}: {
|
||||
title: string;
|
||||
duration: string;
|
||||
status: string;
|
||||
thumbnailColor: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="group flex flex-col bg-white rounded-[20px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-xl hover:shadow-grayScale-400/5 transition-all">
|
||||
{/* Thumbnail Area */}
|
||||
<div className={cn("h-44 w-full relative", thumbnailColor)}>
|
||||
<div className="absolute bottom-3 right-3 bg-black/60 text-white text-[11px] font-bold px-2 py-0.5 rounded backdrop-blur-sm">
|
||||
{duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 flex flex-col flex-1 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-2 border",
|
||||
status === "Published"
|
||||
? "bg-[#F0FDF4] text-[#16A34A] border-[#DCFCE7]"
|
||||
: "bg-grayScale-50 text-grayScale-400 border-grayScale-100",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
status === "Published" ? "bg-[#16A34A]" : "bg-grayScale-300",
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</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>
|
||||
|
||||
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<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 flex items-center justify-center gap-2 text-xs hover:bg-grayScale-25"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full h-10 rounded-[10px] font-bold text-xs shadow-sm",
|
||||
status === "Published"
|
||||
? "bg-[#ECD5E9] text-[#9E2891] hover:bg-[#EBD0E7]"
|
||||
: "bg-brand-500 text-white hover:bg-brand-600",
|
||||
)}
|
||||
>
|
||||
{status === "Published" ? "Published" : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
87
src/pages/content-management/CreateQuestionTypeFlow.tsx
Normal file
87
src/pages/content-management/CreateQuestionTypeFlow.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep";
|
||||
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep";
|
||||
|
||||
export function CreateQuestionTypeFlow() {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const steps = [
|
||||
"Basic Info",
|
||||
"Input & Answer Configuration",
|
||||
"Versions",
|
||||
"Review & Publish",
|
||||
];
|
||||
|
||||
const handleNext = () =>
|
||||
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
||||
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
|
||||
<div className="max-w-[1440px] mx-auto py-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
to="/new-content/question-types"
|
||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 group"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Question Type Library
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
|
||||
Create Question Type
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[14px] font-medium">
|
||||
Create a new immersive practice session for students.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
|
||||
onClick={() => navigate("/new-content/question-types")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all">
|
||||
Save as Draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 mx-auto">
|
||||
<Stepper steps={steps} currentStep={currentStep} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />}
|
||||
{currentStep === 2 && (
|
||||
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
|
||||
)}
|
||||
{currentStep > 2 && (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm">
|
||||
<p className="text-grayScale-400 font-medium">
|
||||
Step {currentStep} implementation in progress...
|
||||
</p>
|
||||
<Button onClick={handleBack} variant="outline" className="mt-4">
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1310
src/pages/content-management/HumanLanguageHierarchyPage.tsx
Normal file
1310
src/pages/content-management/HumanLanguageHierarchyPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
706
src/pages/content-management/LearnEnglishPage.tsx
Normal file
706
src/pages/content-management/LearnEnglishPage.tsx
Normal file
|
|
@ -0,0 +1,706 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||
import alertSrc from "../../assets/Alert.svg";
|
||||
import {
|
||||
getLearningPrograms,
|
||||
createLearningProgram,
|
||||
updateLearningProgram,
|
||||
deleteLearningProgram,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import type { LearningProgramListItem } from "../../types/course.types";
|
||||
|
||||
export function LearnEnglishPage() {
|
||||
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [editingProgram, setEditingProgram] =
|
||||
useState<LearningProgramListItem | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [deletingProgram, setDeletingProgram] =
|
||||
useState<LearningProgramListItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const openEdit = (program: LearningProgramListItem) => {
|
||||
setEditingProgram(program);
|
||||
setEditName(program.name ?? "");
|
||||
setEditDescription(program.description?.trim() ?? "");
|
||||
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
setEditingProgram(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditThumbnail("");
|
||||
setUploadingEditThumbnail(false);
|
||||
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleEditThumbnailFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 5 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
||||
return;
|
||||
}
|
||||
setUploadingEditThumbnail(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
setEditThumbnail(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setUploadingEditThumbnail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCreateFormFields = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateThumbnail("");
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
createThumbnailFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDialogOpenChange = (open: boolean) => {
|
||||
if (!open && (createSaving || createUploadingThumbnail)) return;
|
||||
clearCreateFormFields();
|
||||
setCreateOpen(open);
|
||||
};
|
||||
|
||||
const handleCreateThumbnailFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 5 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
||||
return;
|
||||
}
|
||||
setCreateUploadingThumbnail(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
setCreateThumbnail(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setCreateUploadingThumbnail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProgram = async () => {
|
||||
const name = createName.trim();
|
||||
if (!name) {
|
||||
toast.error("Program name is required");
|
||||
return;
|
||||
}
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await createLearningProgram({
|
||||
name,
|
||||
description: createDescription.trim(),
|
||||
thumbnail: createThumbnail.trim(),
|
||||
});
|
||||
toast.success("Program created");
|
||||
clearCreateFormFields();
|
||||
setCreateOpen(false);
|
||||
await fetchPrograms();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create program";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setCreateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingProgram) return;
|
||||
const name = editName.trim();
|
||||
if (!name) {
|
||||
toast.error("Program name is required");
|
||||
return;
|
||||
}
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateLearningProgram(editingProgram.id, {
|
||||
name,
|
||||
description: editDescription.trim(),
|
||||
thumbnail: editThumbnail.trim(),
|
||||
});
|
||||
toast.success("Program updated");
|
||||
closeEdit();
|
||||
await fetchPrograms();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update program";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingProgram) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteLearningProgram(deletingProgram.id);
|
||||
toast.success("Program deleted");
|
||||
setDeletingProgram(null);
|
||||
await fetchPrograms();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete program";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPrograms = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getLearningPrograms({ limit: 100, offset: 0 });
|
||||
const raw = res.data?.data?.programs;
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
const sorted = [...list].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
);
|
||||
setPrograms(sorted);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Failed to load programs");
|
||||
setPrograms([]);
|
||||
toast.error("Could not load programs", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPrograms();
|
||||
}, [fetchPrograms]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header section */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||
Learn English
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-500">
|
||||
Manage learning content by program — cards load from the server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={handleCreateDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="h-11 rounded-[6px] bg-brand-500 px-6 font-semibold ">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Add Program
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden border-none p-0">
|
||||
<div className="shrink-0">
|
||||
<DialogHeader className="p-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Program
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a learning program via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /programs
|
||||
</code>
|
||||
. Thumbnail can be a URL or a file uploaded through{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleCreateProgram();
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Program Name
|
||||
</label>
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="e.g. Intermediate Track"
|
||||
className="h-12 rounded-xl ring-0"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Short summary of the program"
|
||||
rows={3}
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Thumbnail
|
||||
</label>
|
||||
<input
|
||||
ref={createThumbnailFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleCreateThumbnailFile(e)}
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative w-full cursor-pointer rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-left transition-all hover:border-[#9E289180] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={uploadIcon}
|
||||
alt=""
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
{createUploadingThumbnail ? "Uploading…" : "Click to upload"}
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or paste a URL below
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
JPG, PNG (max 5 MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{createThumbnail.trim() ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={createThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-28 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={createThumbnail}
|
||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
placeholder="https://…"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() => handleCreateDialogOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
>
|
||||
{createSaving ? "Creating…" : "Create Program"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
<p className="mt-3 text-sm text-grayScale-500">Loading programs…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => void fetchPrograms()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : programs.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No programs yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Add programs in the backend or use Add Program when it is connected.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{programs.map((program) => (
|
||||
<Card
|
||||
key={program.id}
|
||||
className="group relative w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div
|
||||
className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${program.name}`}
|
||||
onClick={() => openEdit(program)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${program.name}`}
|
||||
onClick={() => setDeletingProgram(program)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center"
|
||||
style={
|
||||
program.thumbnail?.trim()
|
||||
? {
|
||||
backgroundImage: `url(${program.thumbnail.trim()})`,
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
|
||||
<div className="flex-1 min-h-0">
|
||||
<h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
|
||||
{program.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-grayScale-500 line-clamp-4">
|
||||
{program.description?.trim()
|
||||
? program.description
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/new-content/learn-english/${program.id}/courses`}
|
||||
className="mt-4 block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
View Courses
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={editingProgram !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
||||
if (!open) closeEdit();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit program</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and thumbnail. Upload an image from your
|
||||
computer (via file storage) or paste a URL. Changes are saved to the
|
||||
server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Program name"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="rounded-xl resize-y min-h-[100px]"
|
||||
placeholder="Short summary of the program"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Thumbnail
|
||||
</label>
|
||||
<input
|
||||
ref={editThumbnailFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleEditThumbnailFile(e)}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
||||
>
|
||||
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
|
||||
</Button>
|
||||
{editThumbnail.trim() ? (
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={editThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-24 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={editThumbnail}
|
||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Or paste image URL (https://…)"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Local images are sent to{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /files/upload
|
||||
</code>
|
||||
; the returned URL is stored as the program thumbnail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => void handleSaveEdit()}
|
||||
>
|
||||
{savingEdit ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{deletingProgram && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||
<h2 className="text-lg font-bold text-grayScale-700">Delete program</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !deleting && setDeletingProgram(null)}
|
||||
disabled={deleting}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-700">{deletingProgram.name}</span>? This action cannot be
|
||||
undone. Courses under this program may be affected depending on your backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeletingProgram(null)}
|
||||
disabled={deleting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
||||
disabled={deleting}
|
||||
onClick={() => void handleConfirmDelete()}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
676
src/pages/content-management/ModuleDetailPage.tsx
Normal file
676
src/pages/content-management/ModuleDetailPage.tsx
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Video,
|
||||
Calendar,
|
||||
Mic,
|
||||
Layers,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteTopLevelModuleLesson,
|
||||
getModuleLessons,
|
||||
getTopLevelCourseModules,
|
||||
updateTopLevelModuleLesson,
|
||||
} from "../../api/courses.api";
|
||||
import type { TopLevelModuleLessonItem } from "../../types/course.types";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
||||
import { VideoCard } from "./components/VideoCard";
|
||||
|
||||
const LESSON_THUMB_GRADIENTS = [
|
||||
"from-[#CBD5E1] to-[#94A3B8]",
|
||||
"from-[#DBEAFE] to-[#93C5FD]",
|
||||
"from-[#FEF3C7] to-[#FCD34D]",
|
||||
"from-[#FCE7F3] to-[#F9A8D4]",
|
||||
] as const;
|
||||
|
||||
const MOCK_PRACTICES = [
|
||||
{
|
||||
id: "p1",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
];
|
||||
|
||||
type ModuleDetailState = {
|
||||
moduleName?: string;
|
||||
moduleDescription?: string;
|
||||
};
|
||||
|
||||
export function ModuleDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navState = location.state as ModuleDetailState | null;
|
||||
const { level, courseId, moduleId } = useParams<{
|
||||
level: string;
|
||||
courseId: string;
|
||||
moduleId: string;
|
||||
}>();
|
||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||
const [activeFilter, setActiveFilter] = useState("Draft");
|
||||
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
|
||||
const [lessonsLoading, setLessonsLoading] = useState(true);
|
||||
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
||||
const [editingLesson, setEditingLesson] =
|
||||
useState<TopLevelModuleLessonItem | null>(null);
|
||||
const [editLessonTitle, setEditLessonTitle] = useState("");
|
||||
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
|
||||
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
|
||||
const [editLessonDescription, setEditLessonDescription] = useState("");
|
||||
const [savingLessonEdit, setSavingLessonEdit] = useState(false);
|
||||
const [thumbUploadBusy, setThumbUploadBusy] = useState(false);
|
||||
const [videoUploadBusy, setVideoUploadBusy] = useState(false);
|
||||
const lessonMediaUploadBusy = thumbUploadBusy || videoUploadBusy;
|
||||
const [deletingLesson, setDeletingLesson] =
|
||||
useState<TopLevelModuleLessonItem | null>(null);
|
||||
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
|
||||
const [practices] = useState(MOCK_PRACTICES);
|
||||
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
|
||||
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [moduleListResolved, setModuleListResolved] = useState(
|
||||
Boolean(navState?.moduleName?.trim()),
|
||||
);
|
||||
|
||||
const moduleTitleFallback =
|
||||
moduleId
|
||||
?.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ") || "Module";
|
||||
|
||||
const displayModuleName =
|
||||
navState?.moduleName?.trim() ||
|
||||
loadedModuleName ||
|
||||
moduleTitleFallback;
|
||||
|
||||
const hasNavName = Boolean(navState?.moduleName?.trim());
|
||||
|
||||
const displayModuleDescription = (() => {
|
||||
if (hasNavName) {
|
||||
return navState?.moduleDescription?.trim() || "—";
|
||||
}
|
||||
if (!moduleListResolved) {
|
||||
return "Loading…";
|
||||
}
|
||||
if (loadedModuleDescription !== null) {
|
||||
return loadedModuleDescription.trim() || "—";
|
||||
}
|
||||
return "—";
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (navState?.moduleName?.trim()) {
|
||||
return;
|
||||
}
|
||||
const id = Number(moduleId);
|
||||
const cid = Number(courseId);
|
||||
if (!Number.isFinite(id) || id < 1 || !Number.isFinite(cid) || cid < 1) {
|
||||
setModuleListResolved(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getTopLevelCourseModules(cid, { limit: 100, offset: 0 });
|
||||
if (cancelled) return;
|
||||
const list = res.data?.data?.modules;
|
||||
if (Array.isArray(list)) {
|
||||
const m = list.find((mod) => mod.id === id);
|
||||
if (m) {
|
||||
setLoadedModuleName(m.name);
|
||||
setLoadedModuleDescription(m.description ?? "");
|
||||
} else {
|
||||
setLoadedModuleName(null);
|
||||
setLoadedModuleDescription("");
|
||||
}
|
||||
} else {
|
||||
setLoadedModuleName(null);
|
||||
setLoadedModuleDescription(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadedModuleName(null);
|
||||
setLoadedModuleDescription(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setModuleListResolved(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navState?.moduleName, courseId, moduleId]);
|
||||
|
||||
const loadModuleLessons = useCallback(
|
||||
async (options?: { showPageLoading?: boolean }) => {
|
||||
const showPageLoading = options?.showPageLoading ?? true;
|
||||
const mid = Number(moduleId);
|
||||
if (!Number.isFinite(mid) || mid < 1) {
|
||||
setLessons([]);
|
||||
setLessonsLoadError(null);
|
||||
setLessonsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (showPageLoading) {
|
||||
setLessonsLoading(true);
|
||||
setLessonsLoadError(null);
|
||||
}
|
||||
try {
|
||||
const res = await getModuleLessons(mid, { limit: 100, offset: 0 });
|
||||
const list = res.data?.data?.lessons;
|
||||
if (Array.isArray(list)) {
|
||||
setLessons(
|
||||
[...list].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setLessons([]);
|
||||
}
|
||||
if (showPageLoading) {
|
||||
setLessonsLoadError(null);
|
||||
}
|
||||
} catch {
|
||||
if (showPageLoading) {
|
||||
setLessons([]);
|
||||
setLessonsLoadError("Failed to load lessons. Please try again.");
|
||||
} else {
|
||||
toast.error("Failed to refresh lessons");
|
||||
}
|
||||
} finally {
|
||||
if (showPageLoading) {
|
||||
setLessonsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[moduleId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModuleLessons({ showPageLoading: true });
|
||||
}, [loadModuleLessons]);
|
||||
|
||||
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
|
||||
setEditingLesson(lesson);
|
||||
setEditLessonTitle(lesson.title ?? "");
|
||||
setEditLessonVideoUrl(lesson.video_url ?? "");
|
||||
setEditLessonThumbnail(lesson.thumbnail ?? "");
|
||||
setEditLessonDescription(lesson.description ?? "");
|
||||
};
|
||||
|
||||
const closeEditLesson = () => {
|
||||
if (savingLessonEdit || lessonMediaUploadBusy) return;
|
||||
setEditingLesson(null);
|
||||
};
|
||||
|
||||
const handleSaveLessonEdit = async () => {
|
||||
if (!editingLesson) return;
|
||||
const title = editLessonTitle.trim();
|
||||
if (!title) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
setSavingLessonEdit(true);
|
||||
try {
|
||||
await updateTopLevelModuleLesson(editingLesson.id, {
|
||||
title,
|
||||
video_url: editLessonVideoUrl.trim(),
|
||||
thumbnail: editLessonThumbnail.trim(),
|
||||
description: editLessonDescription.trim(),
|
||||
});
|
||||
toast.success("Lesson updated");
|
||||
setEditingLesson(null);
|
||||
await loadModuleLessons({ showPageLoading: false });
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update lesson";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingLessonEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteLesson = async () => {
|
||||
if (!deletingLesson) return;
|
||||
setDeletingLessonInFlight(true);
|
||||
try {
|
||||
await deleteTopLevelModuleLesson(deletingLesson.id);
|
||||
toast.success("Lesson deleted");
|
||||
setDeletingLesson(null);
|
||||
await loadModuleLessons({ showPageLoading: false });
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete lesson";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeletingLessonInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
|
||||
{/* Header Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/new-content/learn-english/${level}/courses/${courseId}`}
|
||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Back to Modules
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
|
||||
<div className="">
|
||||
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
||||
{displayModuleName}
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[14px] max-w-2xl">
|
||||
{displayModuleDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Add Practice
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="h-4 w-4 flex items-center justify-center">
|
||||
<span className="text-xl leading-none font-light">+</span>
|
||||
</div>
|
||||
Add Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-grayScale-200">
|
||||
<div className="flex gap-10">
|
||||
<button
|
||||
onClick={() => setActiveTab("video")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-medium transition-all relative",
|
||||
activeTab === "video"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Lesson
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("practice")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-medium transition-all relative",
|
||||
activeTab === "practice"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Practice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-8">
|
||||
{activeTab === "video" ? (
|
||||
lessonsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
||||
Loading lessons…
|
||||
</div>
|
||||
) : lessonsLoadError ? (
|
||||
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
|
||||
{lessonsLoadError}
|
||||
</div>
|
||||
) : lessons.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{lessons.map((lesson, i) => (
|
||||
<VideoCard
|
||||
key={lesson.id}
|
||||
id={lesson.id}
|
||||
title={lesson.title}
|
||||
videoUrl={lesson.video_url}
|
||||
hoverModuleActions
|
||||
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
||||
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
|
||||
onEdit={() => openEditLesson(lesson)}
|
||||
onDelete={() => setDeletingLesson(lesson)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
|
||||
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
|
||||
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
|
||||
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
||||
No lessons in this module yet
|
||||
</h2>
|
||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
||||
Lessons are a great way to engage students. Add your first
|
||||
lesson to get started.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Video className="h-5 w-5" />
|
||||
Add Lesson
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Practice Tab Filter Bar */}
|
||||
<div className="bg-white border border-grayScale-100 rounded-2xl p-4 flex items-center gap-10 shadow-sm overflow-x-auto whitespace-nowrap px-8">
|
||||
<div className="flex items-center gap-2 text-[12px] font-bold text-grayScale-300 uppercase tracking-widest mr-2">
|
||||
STATUS:
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{["All", "Published", "Draft", "Archived"].map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setActiveFilter(label)}
|
||||
className={cn(
|
||||
"h-9 px-5 rounded-full text-[13px] font-bold transition-all",
|
||||
activeFilter === label
|
||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
||||
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Practice Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{practices.map((practice) => (
|
||||
<PracticeCard key={practice.id} {...practice} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function PracticeCard({
|
||||
title,
|
||||
level,
|
||||
variations,
|
||||
status,
|
||||
}: {
|
||||
title: string;
|
||||
level: string;
|
||||
variations: number;
|
||||
status: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
|
||||
{level}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-grayScale-500">
|
||||
<Mic className="h-4 w-4" />
|
||||
<span className="text-[13px] font-bold">Speaking</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span className="text-[14px] font-bold">{variations} Variations</span>
|
||||
</div>
|
||||
|
||||
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
|
||||
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
|
||||
{status}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
|
||||
<Edit2 className="h-5 w-5" />
|
||||
</button>
|
||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-3">
|
||||
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
|
||||
Publish Practice
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
|
||||
>
|
||||
Publish Video
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/pages/content-management/NewContentPage.tsx
Normal file
83
src/pages/content-management/NewContentPage.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Mic } from "lucide-react";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export function NewContentPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header section */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||
Content Management
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-500">
|
||||
Upload, organize, and manage learning content across programs and
|
||||
courses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid max-w-5xl gap-8 grid-cols-1 md:grid-cols-2">
|
||||
{/* Learn English Card */}
|
||||
<Card className="overflow-hidden border-none shadow-soft">
|
||||
<div className="flex h-56 items-center justify-center bg-white/50">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand-100/30">
|
||||
<Mic className="h-10 w-10 text-brand-500" />
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="border-t border-grayScale-200 bg-white p-8 text-center">
|
||||
<h3 className="text-xl font-bold text-grayScale-700">
|
||||
Learn English
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-grayScale-500">
|
||||
Manage structured English learning content based on levels and
|
||||
modules.
|
||||
</p>
|
||||
<Link to="/new-content/learn-english">
|
||||
<Button className="mt-8 h-12 w-full rounded-[6px] bg-brand-500 text-base font-semibold ">
|
||||
Manage Learn English
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Courses Card */}
|
||||
<Card className="overflow-hidden border-none shadow-soft">
|
||||
<div className="flex h-56 items-center justify-center bg-white/50">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand-100/30">
|
||||
<Mic className="h-10 w-10 text-brand-500" />
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="border-t border-grayScale-200 bg-white p-8 text-center">
|
||||
<h3 className="text-xl font-bold text-grayScale-700">Courses</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-grayScale-500">
|
||||
Manage skill-based and exam preparation courses such as Duolingo
|
||||
and IELTS.
|
||||
</p>
|
||||
<Link to="/new-content/courses" className="block w-full">
|
||||
<Button className="mt-8 h-12 w-full rounded-[6px] bg-brand-500 text-base font-semibold ">
|
||||
Manage Courses
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -58,7 +58,13 @@ const typeColors: Record<QuestionType, string> = {
|
|||
}
|
||||
|
||||
export function PracticeQuestionsPage() {
|
||||
const { categoryId, courseId, subModuleId, practiceId } = useParams()
|
||||
const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
|
||||
categoryId: string
|
||||
courseId: string
|
||||
subModuleId?: string
|
||||
levelId?: string
|
||||
practiceId?: string
|
||||
}>()
|
||||
const location = useLocation()
|
||||
|
||||
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
||||
|
|
@ -102,11 +108,14 @@ export function PracticeQuestionsPage() {
|
|||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const backLink = useMemo(() => {
|
||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
|
||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/level/") && levelId) {
|
||||
return "/content/human-language"
|
||||
}
|
||||
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) {
|
||||
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
|
||||
}
|
||||
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
|
||||
}, [location.pathname, categoryId, courseId, subModuleId])
|
||||
}, [location.pathname, categoryId, courseId, subModuleId, levelId])
|
||||
|
||||
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
|
||||
if (type === "TRUE_FALSE") {
|
||||
|
|
|
|||
837
src/pages/content-management/ProgramCoursesPage.tsx
Normal file
837
src/pages/content-management/ProgramCoursesPage.tsx
Normal file
|
|
@ -0,0 +1,837 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||
import alertSrc from "../../assets/Alert.svg";
|
||||
import {
|
||||
createProgramCourse,
|
||||
deleteTopLevelCourse,
|
||||
getLearningPrograms,
|
||||
getProgramCourses,
|
||||
updateTopLevelCourse,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import type {
|
||||
LearningProgramListItem,
|
||||
ProgramCourseListItem,
|
||||
} from "../../types/course.types";
|
||||
|
||||
export function ProgramCoursesPage() {
|
||||
const navigate = useNavigate();
|
||||
/** Route segment is the numeric program id (see Learn English program cards). */
|
||||
const { level: programIdParam } = useParams<{ level: string }>();
|
||||
const programId = Number(programIdParam);
|
||||
|
||||
const [program, setProgram] = useState<LearningProgramListItem | null>(null);
|
||||
const [courses, setCourses] = useState<ProgramCourseListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [deletingCourse, setDeletingCourse] = useState<ProgramCourseListItem | null>(
|
||||
null,
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const [editingCourse, setEditingCourse] = useState<ProgramCourseListItem | null>(
|
||||
null,
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const programIdValid = Number.isFinite(programId) && programId >= 1;
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!Number.isFinite(programId) || programId < 1) {
|
||||
setError("Invalid program");
|
||||
setLoading(false);
|
||||
setCourses([]);
|
||||
setProgram(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [coursesRes, programsRes] = await Promise.all([
|
||||
getProgramCourses(programId, { limit: 100, offset: 0 }),
|
||||
getLearningPrograms({ limit: 100, offset: 0 }),
|
||||
]);
|
||||
|
||||
const programRows = programsRes.data?.data?.programs;
|
||||
const list = Array.isArray(programRows) ? programRows : [];
|
||||
const found = list.find((p) => p.id === programId) ?? null;
|
||||
setProgram(found);
|
||||
|
||||
const raw = coursesRes.data?.data?.courses;
|
||||
const courseList = Array.isArray(raw) ? raw : [];
|
||||
const sorted = [...courseList].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
);
|
||||
setCourses(sorted);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Failed to load courses");
|
||||
setCourses([]);
|
||||
setProgram(null);
|
||||
toast.error("Could not load courses", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [programId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleConfirmDeleteCourse = async () => {
|
||||
if (!deletingCourse) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTopLevelCourse(deletingCourse.id);
|
||||
toast.success("Course deleted");
|
||||
setDeletingCourse(null);
|
||||
await loadData();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete course";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditCourse = (course: ProgramCourseListItem) => {
|
||||
setEditingCourse(course);
|
||||
setEditName(course.name ?? "");
|
||||
setEditDescription(course.description?.trim() ?? "");
|
||||
setEditThumbnail(
|
||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
||||
);
|
||||
};
|
||||
|
||||
const closeEditCourse = () => {
|
||||
setEditingCourse(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditThumbnail("");
|
||||
setUploadingEditThumbnail(false);
|
||||
if (editThumbnailFileInputRef.current) {
|
||||
editThumbnailFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCourseThumbnailFile = 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 handleSaveEditCourse = async () => {
|
||||
if (!editingCourse) return;
|
||||
const name = editName.trim();
|
||||
if (!name) {
|
||||
toast.error("Course name is required");
|
||||
return;
|
||||
}
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateTopLevelCourse(editingCourse.id, {
|
||||
name,
|
||||
description: editDescription.trim(),
|
||||
thumbnail: editThumbnail.trim(),
|
||||
});
|
||||
toast.success("Course updated");
|
||||
closeEditCourse();
|
||||
await loadData();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update course";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCreateCourseForm = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateThumbnail("");
|
||||
setCreateUploadingThumbnail(false);
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
createThumbnailFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCourseDialogOpenChange = (open: boolean) => {
|
||||
if (!open && (createSaving || createUploadingThumbnail)) return;
|
||||
clearCreateCourseForm();
|
||||
setCreateCourseOpen(open);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
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 handleCreateCourse = async () => {
|
||||
if (!programIdValid) return;
|
||||
const name = createName.trim();
|
||||
if (!name) {
|
||||
toast.error("Course name is required");
|
||||
return;
|
||||
}
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await createProgramCourse(programId, {
|
||||
name,
|
||||
description: createDescription.trim(),
|
||||
thumbnail: createThumbnail.trim(),
|
||||
});
|
||||
toast.success("Course created");
|
||||
clearCreateCourseForm();
|
||||
setCreateCourseOpen(false);
|
||||
await loadData();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create course";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setCreateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const programTitle = !programIdValid
|
||||
? "Program not found"
|
||||
: program?.name?.trim() || `Program ${programId}`;
|
||||
const programDescription =
|
||||
program?.description?.trim() ||
|
||||
(!loading && programIdValid && !program
|
||||
? "Program details are unavailable. You can still browse courses below if they loaded."
|
||||
: "");
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pt-10">
|
||||
{/* Navigation */}
|
||||
<Link
|
||||
to="/new-content/learn-english"
|
||||
className="flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-500"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
|
||||
{/* Header section */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-grayScale-700">
|
||||
{programTitle}
|
||||
</h1>
|
||||
{programDescription ? (
|
||||
<p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400">
|
||||
{programDescription}
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<img
|
||||
src={spinnerSrc}
|
||||
alt=""
|
||||
className="h-6 w-6 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{programIdValid ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Add Practice
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Dialog
|
||||
open={createCourseOpen}
|
||||
onOpenChange={handleCreateCourseDialogOpenChange}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
>
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Add Courses
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden border-none p-0">
|
||||
<div className="shrink-0">
|
||||
<DialogHeader className="p-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Course
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a course via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /programs/:program_id/courses
|
||||
</code>
|
||||
. Thumbnail can be a URL or a file from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-100" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleCreateCourse();
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Course Name
|
||||
</label>
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="e.g. Introduction to German A1"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Short summary of the course"
|
||||
rows={3}
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Thumbnail
|
||||
</label>
|
||||
<input
|
||||
ref={createThumbnailFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative w-full cursor-pointer rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-left transition-all hover:border-[#9E289180] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() =>
|
||||
createThumbnailFileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={uploadIcon}
|
||||
alt=""
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
{createUploadingThumbnail
|
||||
? "Uploading…"
|
||||
: "Click to upload"}
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or paste a URL below
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||
JPG, PNG (max 5 MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{createThumbnail.trim() ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={createThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-28 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={createThumbnail}
|
||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
placeholder="https://…"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() => handleCreateCourseDialogOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
>
|
||||
{createSaving ? "Creating…" : "Create Course"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
) : error && courses.length === 0 ? (
|
||||
<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 loadData()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</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 program yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Add courses using the button above when the flow is connected to the
|
||||
API.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{courses.map((course) => {
|
||||
const modules =
|
||||
course.module_count ?? course.modules_count ?? 0;
|
||||
const lessons = course.lesson_count ?? course.videos_count ?? 0;
|
||||
const practices =
|
||||
course.practice_count ?? course.practices_count ?? 0;
|
||||
const thumbnailSrc =
|
||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "";
|
||||
return (
|
||||
<Card
|
||||
key={course.id}
|
||||
className="group relative w-[290px] overflow-hidden border border-grayScale-100 shadow-soft 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 ${course.name}`}
|
||||
onClick={() => openEditCourse(course)}
|
||||
>
|
||||
<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 ${course.name}`}
|
||||
onClick={() => setDeletingCourse(course)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center"
|
||||
style={
|
||||
thumbnailSrc
|
||||
? {
|
||||
backgroundImage: `url(${thumbnailSrc})`,
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-bold text-grayScale-700">
|
||||
{course.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-grayScale-500 line-clamp-2">
|
||||
{course.description?.trim() ? course.description : "—"}
|
||||
</p>
|
||||
|
||||
<div className="my-6 grid grid-cols-3 gap-4 border-y border-grayScale-50 py-4">
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
{modules}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
Modules
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
{lessons}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
Lessons
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
{practices}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
Practices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 flex-1 rounded-[6px] border-brand-500 text-[13px] font-semibold text-brand-500 "
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${programIdParam}/courses/${course.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View Detail
|
||||
</Button>
|
||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold ">
|
||||
Publish Practice
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={editingCourse !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
||||
if (!open) closeEditCourse();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and thumbnail. Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /courses/: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={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Course 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="min-h-[100px] resize-y rounded-xl"
|
||||
placeholder="Short summary"
|
||||
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 handleEditCourseThumbnailFile(e)}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
||||
>
|
||||
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
|
||||
</Button>
|
||||
{editThumbnail.trim() ? (
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={editThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-24 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={editThumbnail}
|
||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Or paste image URL (https://…)"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEditCourse}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => void handleSaveEditCourse()}
|
||||
>
|
||||
{savingEdit ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{deletingCourse && (
|
||||
<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 course</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !deleting && setDeletingCourse(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">
|
||||
{deletingCourse.name}
|
||||
</span>
|
||||
? This cannot be undone. Related modules and content 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={() => setDeletingCourse(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 handleConfirmDeleteCourse()}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
src/pages/content-management/ProgramDetailPage.tsx
Normal file
273
src/pages/content-management/ProgramDetailPage.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
FileText,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
ChevronRight,
|
||||
X,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
|
||||
export function ProgramDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { programType } = useParams<{ programType: string }>();
|
||||
|
||||
// Mock data for "proficiency" program type
|
||||
const programs: Record<string, any> = {
|
||||
proficiency: {
|
||||
title: "English Proficiency Exams",
|
||||
description:
|
||||
"Manage exam-based learning programs such as Duolingo and IELTS.",
|
||||
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": {
|
||||
title: "Skill-Based Courses",
|
||||
description:
|
||||
"Practice-focused communication and skills training for real-world scenarios.",
|
||||
courses: [], // To be implemented or shown if needed
|
||||
},
|
||||
};
|
||||
|
||||
const currentProgram =
|
||||
programs[programType || "proficiency"] || programs.proficiency;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||
{/* Navigation */}
|
||||
<Link
|
||||
to="/new-content/courses"
|
||||
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back
|
||||
</Link>
|
||||
|
||||
{/* Header section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-[26px] font-medium tracking-tight text-grayScale-900">
|
||||
{currentProgram.title}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
||||
{currentProgram.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<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" />
|
||||
Create Course
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
||||
<div className="bg-white">
|
||||
<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">
|
||||
Create Course
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Course Order
|
||||
</label>
|
||||
<Select defaultValue="1">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Field */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Thumbnail
|
||||
</label>
|
||||
<div className="relative group cursor-pointer">
|
||||
<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">
|
||||
Click to upload
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or drag and drop
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
||||
Create Program
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
|
||||
onClick={() =>
|
||||
navigate(`/new-content/courses/${programType}/attach-practice`)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="flex flex-wrap gap-8 mt-10">
|
||||
{currentProgram.courses.map((course: any) => (
|
||||
<Card
|
||||
key={course.id}
|
||||
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"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center">{course.logo}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4 pt-2 flex-1">
|
||||
<h3 className="text-[18px] font-medium text-grayScale-900">
|
||||
{course.name}
|
||||
</h3>
|
||||
<p className="text-[14px] text-grayScale-500 font-medium">
|
||||
{course.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badges/Stats */}
|
||||
<div className="flex items-center pt-4 gap-4">
|
||||
<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" />
|
||||
<span className="text-[12px] ">
|
||||
{course.coursesCount} Courses
|
||||
</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] ">
|
||||
{course.questionTypesCount} Question Types
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
className="w-full mt-4 h-10 bg-brand-500 text-white rounded-[8px] font-bold flex items-center justify-center gap-2 group/btn"
|
||||
onClick={() =>
|
||||
navigate(`/new-content/courses/${programType}/${course.id}`)
|
||||
}
|
||||
>
|
||||
{course.buttonText}
|
||||
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/pages/content-management/ProgramTypeSelectionPage.tsx
Normal file
84
src/pages/content-management/ProgramTypeSelectionPage.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { GraduationCap, Brain } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export function ProgramTypeSelectionPage() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Header section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
||||
Courses
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
||||
Organize courses under skill-based learning or English proficiency
|
||||
exams. Select a program type to manage curriculum and modules.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/new-content/question-types">
|
||||
<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 mt-4">
|
||||
Manage Question Types
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Cards Grid */}
|
||||
<div className="flex flex-warp gap-10 pt-4">
|
||||
{/* Skill-Based Courses Card */}
|
||||
<Link to="/new-content/courses/skill-based" className="group h-full">
|
||||
<div className="bg-white rounded-[6px] w-[500px] border border-grayScale-100 px-10 py-12 h-full transition-all flex flex-col items-start gap-10">
|
||||
<div className="h-16 w-16 rounded-full bg-brand-50/10 flex items-center justify-center">
|
||||
<Brain className="h-8 w-8 text-brand-500" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 flex-1">
|
||||
<h3 className="text-[20px] font-bold text-grayScale-900">
|
||||
Skill-Based Courses
|
||||
</h3>
|
||||
<p className="text-[15px] leading-relaxed text-grayScale-500 font-medium">
|
||||
Practice-focused communication and skills training. Create
|
||||
modules for vocabulary, grammar, and real-world conversation
|
||||
scenarios.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* English Proficiency Exams Card */}
|
||||
<Link to="/new-content/courses/proficiency" className="group h-full">
|
||||
<div className="bg-white w-[500px] rounded-[6px] border border-grayScale-100 px-10 py-12 h-full transition-all flex flex-col items-start gap-10">
|
||||
<div className="h-16 w-16 rounded-full bg-brand-50/10 flex items-center justify-center">
|
||||
<GraduationCap className="h-8 w-8 text-brand-500" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 flex-1">
|
||||
<h3 className="text-[20px] font-bold text-grayScale-900 transition-colors">
|
||||
English Proficiency Exams
|
||||
</h3>
|
||||
<p className="text-[15px] leading-relaxed text-grayScale-500 font-medium">
|
||||
Exam preparation courses such as IELTS, and Duolingo. Structure
|
||||
content by band scores, sections, and mock tests.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/pages/content-management/QuestionTypeLibraryPage.tsx
Normal file
127
src/pages/content-management/QuestionTypeLibraryPage.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft, Plus, Search } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { QuestionTypeCard } from "./components/QuestionTypeCard";
|
||||
|
||||
export function QuestionTypeLibraryPage() {
|
||||
const [activeTab, setActiveTab] = useState("All");
|
||||
|
||||
const questionTypes = [
|
||||
{
|
||||
title: "Describe a Photo",
|
||||
exam: "DUOLINGO" as const,
|
||||
skill: "Speaking" as const,
|
||||
variations: 12,
|
||||
status: "Published" as const,
|
||||
},
|
||||
{
|
||||
title: "Write About the Topic",
|
||||
exam: "DUOLINGO" as const,
|
||||
skill: "Writing" as const,
|
||||
variations: 12,
|
||||
status: "Published" as const,
|
||||
},
|
||||
{
|
||||
title: "Fill in the Blanks",
|
||||
exam: "IELTS" as const,
|
||||
skill: "Writing" as const,
|
||||
variations: 12,
|
||||
status: "Published" as const,
|
||||
},
|
||||
{
|
||||
title: "Describe a Photo",
|
||||
exam: "DUOLINGO" as const,
|
||||
skill: "Speaking" as const,
|
||||
variations: 12,
|
||||
status: "Published" as const,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
||||
{/* Navigation & Header */}
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
to="/new-content/courses"
|
||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Courses
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">
|
||||
Question Type Library
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[16px] font-medium">
|
||||
Create and manage reusable question structures for practices and
|
||||
assessments.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/new-content/question-types/create">
|
||||
<Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3">
|
||||
<Plus className="h-5 w-5" />
|
||||
Create Question Type
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Bar */}
|
||||
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
|
||||
<Input
|
||||
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
|
||||
placeholder="Search by practice name, ID, or keywords..."
|
||||
/>
|
||||
</div>
|
||||
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
|
||||
<option>All Exams</option>
|
||||
<option>IELTS</option>
|
||||
<option>Duolingo</option>
|
||||
</Select>
|
||||
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
|
||||
<option>All Skills</option>
|
||||
<option>Speaking</option>
|
||||
<option>Writing</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
|
||||
STATUS:
|
||||
</span>
|
||||
{["All", "Published", "Drafts", "Archived"].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
|
||||
activeTab === tab
|
||||
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
|
||||
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Grid of Cards */}
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{questionTypes.map((qt, index) => (
|
||||
<QuestionTypeCard key={index} {...qt} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Mic, Plus, Trash2, Upload } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -1926,7 +1926,7 @@ export function SpeakingPage() {
|
|||
className="gap-1.5"
|
||||
>
|
||||
{uploadingIntroVideo ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<SpinnerIcon className="h-4 w-4" alt="" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
|
|
|
|||
143
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal file
143
src/pages/content-management/SubCategoryCoursesPage.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link, useNavigate, useParams } from "react-router-dom"
|
||||
import { ArrowLeft, BookOpen, ChevronRight } from "lucide-react"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import alertSrc from "../../assets/Alert.svg"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { getCoursesBySubCategoryId, getSubCategoriesByCategoryId } from "../../api/courses.api"
|
||||
import type { CategorySubCategoryListItem, SubCategoryCourseListItem } from "../../types/course.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export function SubCategoryCoursesPage() {
|
||||
const { categoryId, subCategoryId } = useParams<{
|
||||
categoryId: string
|
||||
subCategoryId: string
|
||||
}>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [subCategory, setSubCategory] = useState<CategorySubCategoryListItem | null>(null)
|
||||
const [courses, setCourses] = useState<SubCategoryCourseListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
if (!categoryId || !subCategoryId) return
|
||||
const cid = Number(categoryId)
|
||||
const sid = Number(subCategoryId)
|
||||
if (!Number.isFinite(cid) || !Number.isFinite(sid)) {
|
||||
setError("Invalid route parameters")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [subRes, coursesRes] = await Promise.all([
|
||||
getSubCategoriesByCategoryId(cid),
|
||||
getCoursesBySubCategoryId(sid),
|
||||
])
|
||||
const list = subRes.data?.data?.sub_categories ?? []
|
||||
const found = Array.isArray(list) ? list.find((s) => s.id === sid) : undefined
|
||||
setSubCategory(found ?? null)
|
||||
|
||||
const raw = coursesRes.data?.data?.courses
|
||||
setCourses(Array.isArray(raw) ? raw : [])
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError("Failed to load courses for this sub-category")
|
||||
setCourses([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void run()
|
||||
}, [categoryId, subCategoryId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
|
||||
<img src={alertSrc} alt="" className="h-10 w-10 shrink-0" />
|
||||
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const label = subCategory?.name ?? "Sub-category"
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-grayScale-100 bg-white px-5 py-4 shadow-sm sm:px-6 sm:py-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<Link
|
||||
to={`/content/category/${categoryId}/courses`}
|
||||
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-400">Sub-category</p>
|
||||
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">{label}</h1>
|
||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||
{courses.length} course{courses.length !== 1 ? "s" : ""} — open a course to manage sub-modules
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">No courses in this sub-category yet</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">Add a course from your authoring flow or API.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{courses.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(`/content/category/${categoryId}/courses/${c.id}/sub-modules`)
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 rounded-xl border border-grayScale-200 bg-white px-4 py-4 text-left shadow-sm transition-all",
|
||||
"hover:border-brand-200 hover:bg-brand-50/40 hover:shadow-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-grayScale-800">{c.title}</p>
|
||||
{c.description?.trim() ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-sm text-grayScale-500">{c.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<Badge variant={c.is_active ? "success" : "secondary"} className="text-[11px]">
|
||||
{c.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
<ChevronRight className="h-5 w-5 text-grayScale-300" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -103,9 +103,10 @@ export function SubModuleContentPage() {
|
|||
|
||||
try {
|
||||
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
|
||||
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
||||
(sc) => sc.id === Number(subModuleId)
|
||||
)
|
||||
const list = subCoursesRes.data?.data?.sub_courses
|
||||
const foundSubCourse = Array.isArray(list)
|
||||
? list.find((sc) => sc.id === Number(subModuleId))
|
||||
: undefined
|
||||
setSubCourse(foundSubCourse ?? null)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch course data:", err)
|
||||
|
|
@ -123,7 +124,9 @@ export function SubModuleContentPage() {
|
|||
setPracticesLoading(true)
|
||||
try {
|
||||
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
|
||||
setPractices(res.data.data ?? [])
|
||||
const raw = res.data?.data
|
||||
const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
|
||||
setPractices(Array.isArray(list) ? list : [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch practices:", err)
|
||||
} finally {
|
||||
|
|
@ -136,7 +139,8 @@ export function SubModuleContentPage() {
|
|||
setVideosLoading(true)
|
||||
try {
|
||||
const res = await getVideosBySubModule(Number(subModuleId))
|
||||
setVideos(res.data.data.videos ?? [])
|
||||
const vids = res.data?.data?.videos ?? []
|
||||
setVideos(Array.isArray(vids) ? vids : [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch videos:", err)
|
||||
} finally {
|
||||
|
|
@ -154,7 +158,7 @@ export function SubModuleContentPage() {
|
|||
limit: ratingsPageSize,
|
||||
offset,
|
||||
})
|
||||
setRatings(res.data.data ?? [])
|
||||
setRatings(res.data?.data ?? [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch ratings:", err)
|
||||
} finally {
|
||||
|
|
@ -405,8 +409,8 @@ export function SubModuleContentPage() {
|
|||
const idMatch = video.video_url?.match(/(\d{5,})/)
|
||||
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
||||
const res = await getVimeoSample(vimeoId)
|
||||
setPreviewIframe(res.data.data.iframe)
|
||||
setPreviewVideo(res.data.data.video)
|
||||
setPreviewIframe(res.data?.data?.iframe ?? "")
|
||||
setPreviewVideo(res.data?.data?.video ?? null)
|
||||
} catch {
|
||||
setPreviewIframe("")
|
||||
} finally {
|
||||
|
|
@ -414,7 +418,7 @@ export function SubModuleContentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const filteredPractices = practices.filter((practice) => {
|
||||
const filteredPractices = (Array.isArray(practices) ? practices : []).filter((practice) => {
|
||||
if (statusFilter === "all") return true
|
||||
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||
if (statusFilter === "draft") return practice.status === "DRAFT"
|
||||
|
|
@ -440,6 +444,19 @@ export function SubModuleContentPage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!subCourse) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
||||
<p className="mt-3 text-sm font-medium text-grayScale-600">Sub-module not found</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">It may have been removed or the link is invalid.</p>
|
||||
<Button className="mt-6" variant="outline" asChild>
|
||||
<Link to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`}>Back to sub-modules</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back Button */}
|
||||
|
|
@ -590,7 +607,7 @@ export function SubModuleContentPage() {
|
|||
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{practice.owner_type.replace("_", " ")}</span>
|
||||
<span>{String(practice.owner_type ?? "SUB_MODULE").replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
{practice.shuffle_questions && (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
|
||||
|
|
@ -599,11 +616,13 @@ export function SubModuleContentPage() {
|
|||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
|
||||
<span className="text-xs font-medium text-grayScale-400">
|
||||
{new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{practice.created_at
|
||||
? new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
254
src/pages/content-management/UnitManagementPage.tsx
Normal file
254
src/pages/content-management/UnitManagementPage.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
MessageCircle,
|
||||
PlayCircle,
|
||||
ClipboardCheck,
|
||||
ArrowRight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
|
||||
export function UnitManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
const { programType, courseId, unitId } = useParams<{
|
||||
programType: string;
|
||||
courseId: string;
|
||||
unitId: string;
|
||||
}>();
|
||||
|
||||
// Mock titles
|
||||
const unitTitles: Record<string, string> = {
|
||||
unit1: "Greetings & Introductions",
|
||||
unit2: "Speaking",
|
||||
unit3: "Reading",
|
||||
};
|
||||
|
||||
const unitDisplayName =
|
||||
unitTitles[unitId || ""] || "Greetings & Introductions";
|
||||
|
||||
const modules = [
|
||||
{
|
||||
id: "mod1",
|
||||
name: "Module 1: Basic Phrases",
|
||||
description: "Learn essential phrases for daily conversations.",
|
||||
videos: 3,
|
||||
practices: 3,
|
||||
gradient:
|
||||
"linear-gradient(135deg, rgba(158, 40, 145, 0.4) 0%, rgba(158, 40, 145, 0.7) 100%)",
|
||||
},
|
||||
{
|
||||
id: "mod2",
|
||||
name: "Module 1: Basic Phrases", // Matching Image 2092-1 labels
|
||||
description: "Learn essential phrases for daily conversations.",
|
||||
videos: 3,
|
||||
practices: 3,
|
||||
gradient:
|
||||
"linear-gradient(135deg, rgba(79, 70, 229, 0.4) 0%, rgba(79, 70, 229, 0.7) 100%)",
|
||||
},
|
||||
{
|
||||
id: "mod3",
|
||||
name: "Module 1: Basic Phrases",
|
||||
description: "Learn essential phrases for daily conversations.",
|
||||
videos: 3,
|
||||
practices: 3,
|
||||
gradient:
|
||||
"linear-gradient(135deg, rgba(124, 58, 237, 0.4) 0%, rgba(124, 58, 237, 0.7) 100%)",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||
{/* Navigation */}
|
||||
<Link
|
||||
to={`/new-content/courses/${programType}/${courseId}`}
|
||||
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Courses
|
||||
</Link>
|
||||
|
||||
{/* Header section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
|
||||
{unitDisplayName}
|
||||
</h1>
|
||||
|
||||
<Dialog>
|
||||
<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">
|
||||
<Plus className="h-5 w-5" />
|
||||
Add Modules
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
||||
<div className="bg-white">
|
||||
<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">
|
||||
Create Modules
|
||||
</DialogTitle>
|
||||
<DialogClose className="rounded-full p-1.5 hover:bg-grayScale-50 transition-colors">
|
||||
<X className="h-5 w-5 text-grayScale-400" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Module Title
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. 1.1 Exam types"
|
||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Module Order
|
||||
</label>
|
||||
<Select defaultValue="1">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Icon</label>
|
||||
<div className="relative group cursor-pointer">
|
||||
<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">
|
||||
Click to upload
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or drag and drop
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-200 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
||||
Create Module
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid of Modules */}
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
{modules.map((module, index) => (
|
||||
<Card
|
||||
key={`${module.id}-${index}`}
|
||||
className="group flex w-[400px] flex-col bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
||||
>
|
||||
{/* Gradient Header */}
|
||||
<div
|
||||
className="h-36 w-full"
|
||||
style={{ background: module.gradient }}
|
||||
/>
|
||||
|
||||
<div className="p-5 flex flex-col space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Chat Icon */}
|
||||
<div className="mt-1 h-10 w-10 shrink-0 rounded-full bg-[#9E28911A] border border-[#9E289133] flex items-center justify-center">
|
||||
<MessageCircle className="h-5 w-5 text-brand-500" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[16px] font-medium text-grayScale-900 leading-tight">
|
||||
{module.name}
|
||||
</h3>
|
||||
<p className="text-[12px] text-grayScale-500 font-medium">
|
||||
{module.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Pills */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 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" />
|
||||
<span className="text-[12px] font-bold">
|
||||
{module.videos} Videos
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
||||
<ClipboardCheck className="h-3.5 w-3.5 text-grayScale-400" />
|
||||
<span className="text-[12px] font-bold">
|
||||
{module.practices} Practices
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
className="w-full h-10 bg-brand-500 text-white rounded-[6px] font-bold flex items-center justify-center gap-2 group/btn"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${module.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View Detail
|
||||
<ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover/btn:translate-x-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/pages/content-management/components/AddModuleModal.tsx
Normal file
190
src/pages/content-management/components/AddModuleModal.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
||||
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
||||
|
||||
interface AddModuleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
courseId: number;
|
||||
onCreated?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function AddModuleModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0 shadow-2xl">
|
||||
<div className="flex-shrink-0">
|
||||
<DialogHeader className="relative p-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Module
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a module with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /courses/:courseId/modules
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-100" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full opacity-20"
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain p-8 pt-4"
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Module title
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Greetings & Introductions"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Learn to introduce yourself and talk about your life."
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
disabled={submitting}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModuleIconUploadField
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
disabled={submitting}
|
||||
onUploadBusyChange={setIconUploadBusy}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={submitting || iconUploadBusy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold text-white shadow-lg shadow-brand-500/20 hover:bg-brand-600"
|
||||
disabled={submitting || iconUploadBusy}
|
||||
>
|
||||
{submitting ? "Creating…" : "Create module"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
481
src/pages/content-management/components/CreatePracticeWizard.tsx
Normal file
481
src/pages/content-management/components/CreatePracticeWizard.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Check, ChevronLeft, ChevronRight, ListOrdered, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"
|
||||
import { Input } from "../../../components/ui/input"
|
||||
import { Textarea } from "../../../components/ui/textarea"
|
||||
import {
|
||||
addQuestionToSet,
|
||||
createParentLinkedPractice,
|
||||
createQuestion,
|
||||
createQuestionSet,
|
||||
} from "../../../api/courses.api"
|
||||
import type { CreateQuestionRequest, PracticeParentKind } from "../../../types/course.types"
|
||||
import { cn } from "../../../lib/utils"
|
||||
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
|
||||
|
||||
export type CreatePracticeWizardParent = {
|
||||
kind: PracticeParentKind
|
||||
id: number
|
||||
} | null
|
||||
|
||||
const STEPS = [
|
||||
{ n: 1, label: "Question set" },
|
||||
{ n: 2, label: "Questions" },
|
||||
{ n: 3, label: "Attach" },
|
||||
{ n: 4, label: "Practice" },
|
||||
] as const
|
||||
|
||||
type QuestionDraft = {
|
||||
question_text: string
|
||||
voice_prompt: string
|
||||
sample_answer_voice_prompt: string
|
||||
audio_correct_answer_text: string
|
||||
}
|
||||
|
||||
const emptyQuestion = (): QuestionDraft => ({
|
||||
question_text: "",
|
||||
voice_prompt: "",
|
||||
sample_answer_voice_prompt: "",
|
||||
audio_correct_answer_text: "",
|
||||
})
|
||||
|
||||
type Props = {
|
||||
parent: CreatePracticeWizardParent
|
||||
onCreated?: () => void
|
||||
}
|
||||
|
||||
export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [setTitle, setSetTitle] = useState("")
|
||||
const [questionSetId, setQuestionSetId] = useState<number | null>(null)
|
||||
|
||||
const [questionRows, setQuestionRows] = useState<QuestionDraft[]>([emptyQuestion()])
|
||||
const [createdQuestionIds, setCreatedQuestionIds] = useState<number[]>([])
|
||||
|
||||
const [practiceTitle, setPracticeTitle] = useState("")
|
||||
const [storyDescription, setStoryDescription] = useState("")
|
||||
const [storyImage, setStoryImage] = useState("")
|
||||
const [quickTips, setQuickTips] = useState("")
|
||||
|
||||
const canUseWizard = parent != null
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 4 && setTitle.trim() && !practiceTitle.trim()) {
|
||||
setPracticeTitle(setTitle.trim())
|
||||
}
|
||||
}, [step, setTitle, practiceTitle])
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
setStep(1)
|
||||
setSetTitle("")
|
||||
setQuestionSetId(null)
|
||||
setQuestionRows([emptyQuestion()])
|
||||
setCreatedQuestionIds([])
|
||||
setPracticeTitle("")
|
||||
setStoryDescription("")
|
||||
setStoryImage("")
|
||||
setQuickTips("")
|
||||
}, [])
|
||||
|
||||
const handleStep1 = async () => {
|
||||
if (!setTitle.trim()) {
|
||||
toast.error("Enter a title for the question set")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await createQuestionSet({
|
||||
title: setTitle.trim(),
|
||||
set_type: "PRACTICE",
|
||||
})
|
||||
const id = res.data?.data?.id
|
||||
if (id == null) {
|
||||
throw new Error("No question set id in response")
|
||||
}
|
||||
setQuestionSetId(id)
|
||||
toast.success("Question set created")
|
||||
setStep(2)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to create question set")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStep2 = async () => {
|
||||
for (let i = 0; i < questionRows.length; i++) {
|
||||
const r = questionRows[i]
|
||||
if (!r.question_text.trim()) {
|
||||
toast.error(`Question ${i + 1}: enter question text`)
|
||||
return
|
||||
}
|
||||
if (!r.voice_prompt.trim() || !r.sample_answer_voice_prompt.trim()) {
|
||||
toast.error(`Question ${i + 1}: enter voice prompt URLs`)
|
||||
return
|
||||
}
|
||||
if (!r.audio_correct_answer_text.trim()) {
|
||||
toast.error(`Question ${i + 1}: enter the correct answer text`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (questionSetId == null) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const ids: number[] = []
|
||||
for (const r of questionRows) {
|
||||
const body: CreateQuestionRequest = {
|
||||
question_text: r.question_text.trim(),
|
||||
question_type: "AUDIO",
|
||||
voice_prompt: r.voice_prompt.trim(),
|
||||
sample_answer_voice_prompt: r.sample_answer_voice_prompt.trim(),
|
||||
audio_correct_answer_text: r.audio_correct_answer_text.trim(),
|
||||
}
|
||||
const res = await createQuestion(body)
|
||||
const qid = res.data?.data?.id
|
||||
if (qid == null) {
|
||||
throw new Error("A question was created but no id was returned")
|
||||
}
|
||||
ids.push(qid)
|
||||
}
|
||||
setCreatedQuestionIds(ids)
|
||||
toast.success(`Created ${ids.length} question(s)`)
|
||||
setStep(3)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to create questions")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStep3 = async () => {
|
||||
if (questionSetId == null || createdQuestionIds.length === 0) return
|
||||
setSaving(true)
|
||||
try {
|
||||
for (let i = 0; i < createdQuestionIds.length; i++) {
|
||||
await addQuestionToSet(questionSetId, {
|
||||
question_id: createdQuestionIds[i],
|
||||
display_order: i + 1,
|
||||
})
|
||||
}
|
||||
toast.success("Questions linked to the set")
|
||||
setStep(4)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to attach questions")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStep4 = async () => {
|
||||
if (!parent || questionSetId == null) return
|
||||
if (!practiceTitle.trim() || !storyDescription.trim() || !storyImage.trim()) {
|
||||
toast.error("Title, story description, and story image are required")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await createParentLinkedPractice({
|
||||
parent_kind: parent.kind,
|
||||
parent_id: parent.id,
|
||||
title: practiceTitle.trim(),
|
||||
story_description: storyDescription.trim(),
|
||||
story_image: storyImage.trim(),
|
||||
question_set_id: questionSetId,
|
||||
quick_tips: quickTips.trim(),
|
||||
})
|
||||
toast.success("Practice created successfully")
|
||||
resetAll()
|
||||
onCreated?.()
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-brand-200/60 shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-800">Create a new practice</CardTitle>
|
||||
<p className="text-sm font-normal text-grayScale-500">
|
||||
Four steps: create a question set, add audio questions, attach them, then set the practice
|
||||
story. Select the course, module, or lesson above first.
|
||||
</p>
|
||||
<ol className="mt-4 flex flex-wrap gap-2">
|
||||
{STEPS.map((s) => {
|
||||
const done = step > s.n
|
||||
const active = step === s.n
|
||||
return (
|
||||
<li
|
||||
key={s.n}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider",
|
||||
done && "border-mint-500/40 bg-mint-50 text-mint-800",
|
||||
active && !done && "border-brand-500 bg-brand-500 text-white",
|
||||
!active && !done && "border-grayScale-200 bg-white text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="h-3.5 w-3.5" /> : <span className="font-mono tabular-nums">{s.n}</span>}
|
||||
{s.label}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
{!canUseWizard && (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
|
||||
Choose a program, course, and the target (course / module / lesson) in the "Look up
|
||||
practice" section, then return here. The practice is created for the same selection
|
||||
(course id, module id, or lesson id).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Question set title
|
||||
</p>
|
||||
<Input
|
||||
value={setTitle}
|
||||
onChange={(e) => setSetTitle(e.target.value)}
|
||||
placeholder='e.g. "Course-A1 practice"'
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
This calls <span className="font-mono">POST /question-sets</span> with{" "}
|
||||
<span className="font-mono">set_type: PRACTICE</span>.
|
||||
</p>
|
||||
<Button type="button" onClick={handleStep1} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
Create question set & continue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> — add
|
||||
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
|
||||
<span className="font-mono">POST /questions</span>.
|
||||
</p>
|
||||
{questionRows.map((row, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="space-y-3 rounded-2xl border border-grayScale-200 bg-grayScale-50/50 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-grayScale-500">
|
||||
Question {idx + 1}
|
||||
</span>
|
||||
{questionRows.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-600 hover:text-red-700"
|
||||
onClick={() => setQuestionRows((rows) => rows.filter((_, i) => i !== idx))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Question text</p>
|
||||
<Textarea
|
||||
value={row.question_text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, question_text: v } : r)),
|
||||
)
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="Thank you for your help!"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Voice prompt (URL)</p>
|
||||
<Input
|
||||
value={row.voice_prompt}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, voice_prompt: v } : r)),
|
||||
)
|
||||
}}
|
||||
placeholder="https://…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Sample answer voice (URL)</p>
|
||||
<Input
|
||||
value={row.sample_answer_voice_prompt}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, sample_answer_voice_prompt: v } : r)),
|
||||
)
|
||||
}}
|
||||
placeholder="https://…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Correct answer text</p>
|
||||
<Textarea
|
||||
value={row.audio_correct_answer_text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setQuestionRows((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, audio_correct_answer_text: v } : r)),
|
||||
)
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="You're welcome! Have a nice day!"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuestionRows((rows) => [...rows, emptyQuestion()])}
|
||||
disabled={saving}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add another question
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setStep(1)} disabled={saving}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={handleStep2} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
Create questions & continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Link each question to the set with a display order using{" "}
|
||||
<span className="font-mono">POST /question-sets/{id}/questions</span>.
|
||||
</p>
|
||||
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
||||
{createdQuestionIds.map((qid, i) => (
|
||||
<li
|
||||
key={qid}
|
||||
className="flex items-center justify-between gap-2 text-sm text-grayScale-700"
|
||||
>
|
||||
<span className="font-mono">question #{qid}</span>
|
||||
<span className="flex items-center gap-1 text-xs text-grayScale-500">
|
||||
<ListOrdered className="h-3.5 w-3.5" />
|
||||
order {i + 1}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setStep(2)} disabled={saving}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={handleStep3} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
Attach to question set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUseWizard && step === 4 && parent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Parent:{" "}
|
||||
<span className="font-mono text-xs">
|
||||
{parent.kind} #{parent.id}
|
||||
</span>{" "}
|
||||
· question set <span className="font-mono">#{questionSetId}</span> ·{" "}
|
||||
<span className="font-mono">POST /practices</span>
|
||||
</p>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Practice title
|
||||
</p>
|
||||
<Input
|
||||
value={practiceTitle}
|
||||
onChange={(e) => setPracticeTitle(e.target.value)}
|
||||
placeholder="Test title"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Story description
|
||||
</p>
|
||||
<Textarea
|
||||
value={storyDescription}
|
||||
onChange={(e) => setStoryDescription(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Story for the learner…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Story image (URL)
|
||||
</p>
|
||||
<Input
|
||||
value={storyImage}
|
||||
onChange={(e) => setStoryImage(e.target.value)}
|
||||
placeholder="https://…"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Quick tips
|
||||
</p>
|
||||
<Textarea
|
||||
value={quickTips}
|
||||
onChange={(e) => setQuickTips(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Comma-separated tips (optional)"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={handleStep4} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : <ChevronRight className="mr-1.5 h-4 w-4" />}
|
||||
Create practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { CloudUpload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { uploadImageFile, uploadVideoFile } from "../../../api/files.api";
|
||||
|
||||
const MAX_THUMB_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
const THUMB_TYPES = new Set(["image/jpeg", "image/png"]);
|
||||
const VIDEO_TYPES_PREFIX = "video/";
|
||||
|
||||
function isAllowedThumb(file: File): boolean {
|
||||
if (THUMB_TYPES.has(file.type)) return true;
|
||||
const n = file.name.toLowerCase();
|
||||
return /\.(jpe?g|png)$/.test(n);
|
||||
}
|
||||
|
||||
function isAllowedVideoFile(file: File): boolean {
|
||||
if (file.type.startsWith(VIDEO_TYPES_PREFIX)) return true;
|
||||
const n = file.name.toLowerCase();
|
||||
return /\.(mp4|webm|mov|m4v|mkv)$/.test(n);
|
||||
}
|
||||
|
||||
export type LessonMediaUploadKind = "thumbnail" | "video";
|
||||
|
||||
export interface LessonMediaUploadFieldProps {
|
||||
kind: LessonMediaUploadKind;
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
disabled?: boolean;
|
||||
onUploadBusyChange?: (busy: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LessonMediaUploadField({
|
||||
kind,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
onUploadBusyChange,
|
||||
className,
|
||||
}: LessonMediaUploadFieldProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const setBusy = useCallback(
|
||||
(next: boolean) => {
|
||||
setUploading(next);
|
||||
onUploadBusyChange?.(next);
|
||||
},
|
||||
[onUploadBusyChange],
|
||||
);
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (disabled || uploading) return;
|
||||
|
||||
if (kind === "thumbnail") {
|
||||
if (!isAllowedThumb(file)) {
|
||||
toast.error("Please use a JPG or PNG image.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_THUMB_BYTES) {
|
||||
toast.error("Image is too large", {
|
||||
description: "Maximum size is 5 MB.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) throw new Error("Upload did not return a file URL");
|
||||
onChange(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedVideoFile(file)) {
|
||||
toast.error("Please use a video file (e.g. MP4, WebM, MOV).");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_VIDEO_BYTES) {
|
||||
toast.error("Video is too large", {
|
||||
description: "Maximum size is 2 GB.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await uploadVideoFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) throw new Error("Upload did not return a file URL");
|
||||
onChange(url);
|
||||
toast.success("Video uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload video";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[disabled, uploading, kind, onChange, setBusy],
|
||||
);
|
||||
|
||||
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (disabled || uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const zoneDisabled = disabled || uploading;
|
||||
const isThumb = kind === "thumbnail";
|
||||
const label = isThumb ? "Thumbnail" : "Video";
|
||||
const hint = isThumb
|
||||
? "JPG, PNG (MAX 5 MB)"
|
||||
: "MP4, MOV, WebM (MAX 2 GB)";
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={
|
||||
isThumb
|
||||
? "image/jpeg,image/png,.jpg,.jpeg,.png"
|
||||
: "video/*,.mp4,.webm,.mov,.m4v,.mkv"
|
||||
}
|
||||
className="sr-only"
|
||||
onChange={handleFileInputChange}
|
||||
disabled={zoneDisabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={zoneDisabled}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
|
||||
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
|
||||
dragActive && "border-[#9E2891] bg-[#9E289108]",
|
||||
zoneDisabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
{uploading ? (
|
||||
<p className="text-sm font-medium text-grayScale-600">Uploading…</p>
|
||||
) : (
|
||||
<>
|
||||
<CloudUpload
|
||||
className="mb-4 h-10 w-10 text-[#9E2891]"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
|
||||
<span className="text-grayScale-500">or paste a URL below</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||
{hint}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={disabled || uploading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { CloudUpload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { uploadImageFile } from "../../../api/files.api";
|
||||
|
||||
const MAX_ICON_BYTES = 5 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]);
|
||||
|
||||
function isAllowedImageFile(file: File): boolean {
|
||||
if (ALLOWED_IMAGE_TYPES.has(file.type)) return true;
|
||||
const name = file.name.toLowerCase();
|
||||
return /\.(jpe?g|png)$/.test(name);
|
||||
}
|
||||
|
||||
export interface ModuleIconUploadFieldProps {
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
disabled?: boolean;
|
||||
/** Notifies parent so dialogs can block closing while an upload is in flight. */
|
||||
onUploadBusyChange?: (busy: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModuleIconUploadField({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
onUploadBusyChange,
|
||||
className,
|
||||
}: ModuleIconUploadFieldProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const setBusy = useCallback(
|
||||
(next: boolean) => {
|
||||
setUploading(next);
|
||||
onUploadBusyChange?.(next);
|
||||
},
|
||||
[onUploadBusyChange],
|
||||
);
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (disabled || uploading) return;
|
||||
if (!isAllowedImageFile(file)) {
|
||||
toast.error("Please use a JPG or PNG image.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_ICON_BYTES) {
|
||||
toast.error("Image is too large", {
|
||||
description: "Maximum size is 5 MB.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
onChange(url);
|
||||
toast.success("Icon uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload icon";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[disabled, uploading, onChange, setBusy],
|
||||
);
|
||||
|
||||
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (disabled || uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void processFile(file);
|
||||
};
|
||||
|
||||
const zoneDisabled = disabled || uploading;
|
||||
const showSpinner = uploading;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<label className="text-[15px] font-medium text-grayScale-700 md:text-sm">
|
||||
Icon
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png"
|
||||
className="sr-only"
|
||||
onChange={handleFileInputChange}
|
||||
disabled={zoneDisabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={zoneDisabled}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
|
||||
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
|
||||
dragActive && "border-[#9E2891] bg-[#9E289108]",
|
||||
zoneDisabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
{showSpinner ? (
|
||||
<p className="text-sm font-medium text-grayScale-600">Uploading…</p>
|
||||
) : (
|
||||
<>
|
||||
<CloudUpload
|
||||
className="mb-4 h-10 w-10 text-[#9E2891]"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
|
||||
<span className="text-grayScale-500">or paste a URL below</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||
JPG, PNG (MAX 5 MB)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={disabled || uploading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
formatPreviewLength,
|
||||
} from "../../../lib/videoPreview";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
maxSeconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops direct file playback after the first N seconds (admin short preview).
|
||||
*/
|
||||
export function PreviewLimitedFileVideo({
|
||||
src,
|
||||
maxSeconds = DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
}: Props) {
|
||||
const [capped, setCapped] = useState(false);
|
||||
const previewLengthLabel = formatPreviewLength(maxSeconds);
|
||||
|
||||
const onTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (el.currentTime >= maxSeconds) {
|
||||
el.pause();
|
||||
if (el.currentTime > maxSeconds) {
|
||||
el.currentTime = maxSeconds;
|
||||
}
|
||||
setCapped(true);
|
||||
} else {
|
||||
setCapped(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeeking = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (el.currentTime > maxSeconds) {
|
||||
el.currentTime = maxSeconds;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-video w-full object-contain"
|
||||
src={src}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onSeeking={onSeeking}
|
||||
onPlay={() => setCapped(false)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold",
|
||||
capped ? "text-amber-200" : "text-white/95",
|
||||
)}
|
||||
>
|
||||
{capped
|
||||
? `Preview stopped at ${previewLengthLabel} · rewind to rewatch the clip`
|
||||
: `Short clip · playback stops at ${previewLengthLabel}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/pages/content-management/components/QuestionTypeCard.tsx
Normal file
83
src/pages/content-management/components/QuestionTypeCard.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Edit2, Trash2, Mic2, Keyboard, Layers, MicIcon } from "lucide-react";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Card } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface QuestionTypeCardProps {
|
||||
title: string;
|
||||
exam: "DUOLINGO" | "IELTS" | "TOEFL";
|
||||
skill: "Speaking" | "Writing" | "Listening" | "Reading";
|
||||
variations: number;
|
||||
status: "Published" | "Draft" | "Archived";
|
||||
}
|
||||
|
||||
export function QuestionTypeCard({
|
||||
title,
|
||||
exam,
|
||||
skill,
|
||||
variations,
|
||||
status,
|
||||
}: QuestionTypeCardProps) {
|
||||
const SkillIcon = skill === "Speaking" ? MicIcon : Keyboard;
|
||||
|
||||
const examColors = {
|
||||
DUOLINGO: "bg-[#22C55EE5] text-[#fff] border-transparent",
|
||||
IELTS: "bg-[#EF4444E5] text-[#fff] border-transparent",
|
||||
TOEFL: "bg-[#DBEAFE] text-[#fff] border-transparent",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
Published: "bg-[#F0FDF4] text-[#16A34A]",
|
||||
Draft: "bg-grayScale-50 text-grayScale-500",
|
||||
Archived: "bg-red-50 text-red-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300">
|
||||
<div className="px-4 py-6 space-y-8">
|
||||
<h3 className="text-[20px] font-bold text-grayScale-900 leading-[1.2]">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-[4px] text-[11px] font-bold tracking-wider shadow-none border-none",
|
||||
examColors[exam],
|
||||
)}
|
||||
>
|
||||
{exam}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-grayScale-900 font-bold text-[13px]">
|
||||
<SkillIcon className="h-4 w-4" />
|
||||
{skill}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-[#9E2891] font-medium text-[15px]">
|
||||
<Layers className="h-[16px] w-[16px]" />
|
||||
{variations} Variations
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200">
|
||||
<Badge
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none",
|
||||
statusColors[status],
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-5 transition-opacity">
|
||||
<button className="text-grayScale-500/70 transition-all">
|
||||
<Edit2 className="h-5 w-5" />
|
||||
</button>
|
||||
<button className="text-grayScale-500/70 transition-all">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
375
src/pages/content-management/components/VideoCard.tsx
Normal file
375
src/pages/content-management/components/VideoCard.tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { MoreVertical, Edit2, Play, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { isAdminOrSuperAdminRole } from "../../../lib/sessionRole";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
applyShortPreviewToEmbedUrl,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
formatPreviewLength,
|
||||
getVideoPreview,
|
||||
} from "../../../lib/videoPreview";
|
||||
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
|
||||
|
||||
interface VideoCardProps {
|
||||
id?: string | number;
|
||||
title: string;
|
||||
/** Omits the duration chip when not provided (e.g. API has no length yet). */
|
||||
duration?: string;
|
||||
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
|
||||
status?: "Draft" | "Published";
|
||||
thumbnailGradient?: string;
|
||||
thumbnailUrl?: string | null;
|
||||
/**
|
||||
* When set, the hover play control opens a preview (Vimeo, YouTube, or direct
|
||||
* video file) in a dialog.
|
||||
*/
|
||||
videoUrl?: string;
|
||||
/**
|
||||
* When true, shows edit/delete in the top-right of the thumbnail (same
|
||||
* hover pattern as module cards) and removes the footer + overflow menu.
|
||||
*/
|
||||
hoverModuleActions?: boolean;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onPublish?: () => void;
|
||||
}
|
||||
|
||||
export function VideoCard({
|
||||
title,
|
||||
duration,
|
||||
status,
|
||||
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
|
||||
thumbnailUrl,
|
||||
videoUrl,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPublish,
|
||||
hoverModuleActions = false,
|
||||
}: VideoCardProps) {
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
/** Iframe players ignore URL limits in many cases — unmount after real time. */
|
||||
const [iframeSessionDone, setIframeSessionDone] = useState(false);
|
||||
const [iframeSessionKey, setIframeSessionKey] = useState(0);
|
||||
const useGradient = !thumbnailUrl?.trim() || thumbFailed;
|
||||
const videoPreview = useMemo(
|
||||
() => (videoUrl?.trim() ? getVideoPreview(videoUrl) : { kind: "none" as const }),
|
||||
[videoUrl],
|
||||
);
|
||||
const limitedEmbedSrc = useMemo(() => {
|
||||
if (videoPreview.kind !== "iframe") return null;
|
||||
return applyShortPreviewToEmbedUrl(
|
||||
videoPreview.src,
|
||||
videoPreview.label,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
);
|
||||
}, [videoPreview]);
|
||||
const canPreview = Boolean(videoUrl?.trim());
|
||||
const previewLengthLabel = formatPreviewLength(
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewOpen) {
|
||||
setIframeSessionDone(false);
|
||||
return;
|
||||
}
|
||||
if (videoPreview.kind !== "iframe" || !limitedEmbedSrc) {
|
||||
return;
|
||||
}
|
||||
if (iframeSessionDone) {
|
||||
return;
|
||||
}
|
||||
const ms = DEFAULT_PREVIEW_MAX_SECONDS * 1000;
|
||||
const id = window.setTimeout(() => {
|
||||
setIframeSessionDone(true);
|
||||
}, ms);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [
|
||||
previewOpen,
|
||||
videoPreview.kind,
|
||||
limitedEmbedSrc,
|
||||
iframeSessionDone,
|
||||
]);
|
||||
|
||||
const handlePreviewOpenChange = (open: boolean) => {
|
||||
setPreviewOpen(open);
|
||||
if (!open) {
|
||||
setIframeSessionDone(false);
|
||||
setIframeSessionKey((k) => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col",
|
||||
)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-44 w-full overflow-hidden",
|
||||
useGradient && "bg-gradient-to-br",
|
||||
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 ? (
|
||||
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
|
||||
{duration}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Play: opens preview dialog when videoUrl is set */}
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-[8] flex cursor-pointer items-center justify-center bg-gradient-to-b from-black/0 via-black/20 to-black/30 opacity-0 transition-all duration-300 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
aria-label={`Play preview: ${title}`}
|
||||
>
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full border border-white/40 bg-white/20 shadow-lg backdrop-blur-md transition-transform duration-300 group-hover:scale-105 group-hover:border-white/50 group-hover:bg-white/30">
|
||||
<Play className="h-6 w-6 text-white" fill="currentColor" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute inset-0 z-[5] flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
|
||||
<Play className="h-6 w-6 text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={previewOpen} onOpenChange={handlePreviewOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-4xl w-[min(100vw-1.5rem,56rem)] gap-0 overflow-hidden rounded-2xl border border-grayScale-200 p-0 shadow-2xl"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-[#F8FAFC] to-white px-5 py-4 pr-12 sm:px-6 sm:pr-14">
|
||||
<DialogHeader className="space-y-0.5 p-0 text-left">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-500">
|
||||
Short preview
|
||||
</p>
|
||||
<DialogTitle className="line-clamp-2 text-left text-base font-bold leading-snug text-grayScale-900 sm:text-lg">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<p className="pt-0.5 text-left text-xs font-medium text-grayScale-500">
|
||||
The player closes automatically after {previewLengthLabel} in
|
||||
this window (YouTube/Vimeo can’t be trimmed reliably). For the
|
||||
full lesson, use your LMS app.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="bg-black">
|
||||
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
|
||||
iframeSessionDone ? (
|
||||
<div className="flex min-h-[220px] flex-col items-center justify-center gap-3 bg-gradient-to-b from-grayScale-900 to-grayScale-950 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
Preview time in this window has ended
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-white/60">
|
||||
The embed is removed after {previewLengthLabel} of real time
|
||||
so the full video is not available here.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-1 font-bold"
|
||||
onClick={() => {
|
||||
setIframeSessionDone(false);
|
||||
setIframeSessionKey((k) => k + 1);
|
||||
}}
|
||||
>
|
||||
Start preview again
|
||||
</Button>
|
||||
{videoUrl && isAdminOrSuperAdminRole() ? (
|
||||
<a
|
||||
href={videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-semibold text-brand-300 underline-offset-2 hover:underline"
|
||||
>
|
||||
Open full video in new tab
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative aspect-video w-full">
|
||||
<iframe
|
||||
key={`${iframeSessionKey}-${limitedEmbedSrc}`}
|
||||
src={limitedEmbedSrc}
|
||||
title={`${videoPreview.label} preview: ${title}`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
|
||||
Stops in {previewLengthLabel} (hard limit)
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : videoPreview.kind === "video" ? (
|
||||
<PreviewLimitedFileVideo
|
||||
src={videoPreview.src}
|
||||
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 bg-grayScale-900 px-6 py-10 text-center">
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
This link can’t be played inline
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-white/50">
|
||||
Use a Vimeo, YouTube, or direct URL to a video file (e.g. MP4)
|
||||
for an embedded preview.
|
||||
</p>
|
||||
{videoUrl ? (
|
||||
<a
|
||||
href={videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 text-sm font-semibold text-brand-300 underline-offset-2 hover:underline"
|
||||
>
|
||||
Open in new tab
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4 flex-1 flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
hoverModuleActions ? "justify-start" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{/* 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 min-w-0",
|
||||
status === "Published"
|
||||
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border border-[#E5E7EB] bg-grayScale-50 text-grayScale-500">
|
||||
<div className="h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#9CA3AF]" />
|
||||
Lesson
|
||||
</div>
|
||||
)}
|
||||
{!hoverModuleActions ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"
|
||||
>
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Actions (footer) — not used for API lesson cards with hover tools */}
|
||||
{!hoverModuleActions ? (
|
||||
<div className="pt-2 space-y-3 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
{status ? (
|
||||
<Button
|
||||
disabled={status === "Published"}
|
||||
onClick={onPublish}
|
||||
className={cn(
|
||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
||||
status === "Published"
|
||||
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
|
||||
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
|
||||
)}
|
||||
>
|
||||
{status === "Published" ? "Published" : "Publish"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import {
|
||||
Rocket,
|
||||
GraduationCap,
|
||||
Folder,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
Video as VideoIcon,
|
||||
ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
interface AttachPracticeReviewStepProps {
|
||||
formData: any;
|
||||
prevStep: () => void;
|
||||
onPublish: () => void;
|
||||
}
|
||||
|
||||
export function AttachPracticeReviewStep({
|
||||
formData,
|
||||
prevStep,
|
||||
onPublish,
|
||||
}: AttachPracticeReviewStepProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||
|
||||
const questions = [
|
||||
{ order: "01", text: "What is the main idea of the passage?" },
|
||||
{ order: "02", text: "What does the speaker mainly talk about in the..." },
|
||||
{ order: "03", text: "Which option best completes the sentence?" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-500 mx-auto">
|
||||
{/* 1. Video Summary Card */}
|
||||
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white overflow-hidden">
|
||||
<div className="flex gap-8 items-start">
|
||||
{/* Thumbnail */}
|
||||
<div className="relative h-[150px] w-[260px] rounded-xl overflow-hidden shadow-inner flex-shrink-0">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1557425955-df376b5903c8?auto=format&fit=crop&q=80&w=600"
|
||||
alt="Video Thumbnail"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-[11px] font-bold px-2 py-0.5 rounded flex items-center gap-1.5">
|
||||
<VideoIcon className="h-3 w-3" />
|
||||
12:30
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="pt-12">
|
||||
<h3 className="text-[20px] font-bold text-grayScale-900 leading-tight">
|
||||
Intro to Interactive Speaking
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="h-8 pr-2 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
IELTS
|
||||
</div>
|
||||
<div className="h-8 pr-2 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
|
||||
<Folder className="h-4 w-4" />
|
||||
Unit 2: Speaking
|
||||
</div>
|
||||
<div className="h-8 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
|
||||
<Folder className="h-4 w-4" />
|
||||
Module 4: Interactive Speaking
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 2. Attached Practices Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between ">
|
||||
<h2 className="text-[20px] font-bold text-[#0F172A] flex items-center gap-3">
|
||||
Attached Practices
|
||||
</h2>
|
||||
<span className="h-6 px-3 rounded-full bg-grayScale-200/40 text-grayScale-500 text-[12px] font-bold flex items-center justify-center">
|
||||
Total Items: 3
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-6 flex items-center justify-between transition-colors hover:bg-grayScale-25 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-[#FDF2F8] flex items-center justify-center text-[#9E2891]">
|
||||
<ClipboardList className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-[17px] font-medium text-grayScale-900">
|
||||
Multiple Choice
|
||||
</h4>
|
||||
<p className="text-[14px] text-grayScale-400 font-medium">
|
||||
3 Questions • ~4 min to complete
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="flex items-center gap-2 text-[#9E2891] font-medium text-[14px] hover:opacity-80 transition-opacity">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-6 w-6 text-grayScale-300" />
|
||||
) : (
|
||||
<ChevronDown className="h-6 w-6 text-grayScale-300" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content (Table) */}
|
||||
{isExpanded && (
|
||||
<div className="animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="border-t border-grayScale-100">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-white">
|
||||
<th className="py-4 pl-10 pr-0 w-[80px]"></th>
|
||||
<th className="py-4 px-4 text-[13px] font-medium text-[#A5B4C1] uppercase tracking-wide w-24">
|
||||
Order
|
||||
</th>
|
||||
<th className="py-4 px-4 text-[13px] font-medium text-[#A5B4C1] uppercase tracking-wide">
|
||||
Versions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="border-t border-grayScale-100">
|
||||
{questions.map((q, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-grayScale-200 last:border-0 group hover:bg-grayScale-25 transition-colors"
|
||||
>
|
||||
<td className="py-6 pl-10 pr-0 text-center">
|
||||
<GripVertical className="h-4 w-4 text-[#A5B4C1]" />
|
||||
</td>
|
||||
<td className="py-6 px-4">
|
||||
<span className="text-[15px] text-[#A5B4C1]">
|
||||
{q.order}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-6 px-4">
|
||||
<p className="text-[15px] font-medium text-[#0D1421]">
|
||||
{q.text}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 3. Confirmation Checkbox */}
|
||||
<div className="bg-[#F1F5F9] border border-[#E2E8F0] px-6 py-4 rounded-[12px] flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="confirm"
|
||||
checked={isConfirmed}
|
||||
onChange={(e) => setIsConfirmed(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500 cursor-pointer"
|
||||
/>
|
||||
<div className="">
|
||||
<label
|
||||
htmlFor="confirm"
|
||||
className="text-[16px] font-bold text-grayScale-900 cursor-pointer"
|
||||
>
|
||||
I confirm these details are correct
|
||||
</label>
|
||||
<p className="text-[13px] text-grayScale-400">
|
||||
This action cannot be undone immediately. Rollback requires manual
|
||||
intervention.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Action Footer */}
|
||||
<div className="flex items-center justify-between pt-10 px-2">
|
||||
<Button
|
||||
onClick={prevStep}
|
||||
variant="outline"
|
||||
className="h-12 px-10 rounded-[6px] bg-transparent border-grayScale-400 font-bold text-grayScale-600 transition-all"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-none transition-all"
|
||||
>
|
||||
Save as Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onPublish}
|
||||
disabled={!isConfirmed}
|
||||
className={cn(
|
||||
"h-12 px-10 rounded-[6px] font-bold text-white shadow-xl flex items-center gap-3 transition-all active:scale-95",
|
||||
isConfirmed
|
||||
? "bg-[#9E2891] hover:bg-[#8A237E] shadow-[#9E2891]/20"
|
||||
: "bg-grayScale-200 cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Rocket className="h-5 w-5" />
|
||||
Publish Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add GripVertical helper since it might not be imported from lucide-react if I missed it
|
||||
function GripVertical({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 gap-0.5", className)}>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-0.5 w-0.5 rounded-full bg-current" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { LayoutGrid, Video, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Select } from "../../../../components/ui/select";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
interface AttachPracticeStep1Props {
|
||||
formData: any;
|
||||
setFormData: (data: any) => void;
|
||||
nextStep: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AttachPracticeStep1({
|
||||
formData,
|
||||
setFormData,
|
||||
nextStep,
|
||||
onCancel,
|
||||
}: AttachPracticeStep1Props) {
|
||||
return (
|
||||
<Card className="overflow-hidden max-w-4xl mx-auto border-grayScale-100 rounded-3xl bg-white shadow-sm animate-in fade-in duration-500">
|
||||
<div className="space-y-6 p-12">
|
||||
{/* Select Program */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Select Program
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
||||
value={formData.program}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, program: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose Program</option>
|
||||
<option value="skill">Skill-Based Courses</option>
|
||||
<option value="exams">English Proficiency Exams</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select Module */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Select Module
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
||||
value={formData.module}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, module: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose Module</option>
|
||||
<option value="m1">Module 1: Basic Phrases</option>
|
||||
<option value="m2">Module 2: Intermediate Grammar</option>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
||||
Select the specific learning module this practice will reinforce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Select Video */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Select Video
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<Video className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
||||
value={formData.video}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, video: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose a video</option>
|
||||
<option value="v1">Intro to Interactive Speaking</option>
|
||||
<option value="v2">Business Meeting Etiquette</option>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
||||
Select the specific video this practice will reinforce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Select Question Type */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Select Question Type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
||||
value={formData.questionType}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, questionType: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose question type</option>
|
||||
<option value="speaking">Speaking Practice</option>
|
||||
<option value="listening">Listening Quiz</option>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
||||
Select one question type that associates with th selected video
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Set Version */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Set Version
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
||||
value={formData.version}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, version: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose versions</option>
|
||||
<option value="v1">Version 1.0</option>
|
||||
<option value="v2">Version 2.0</option>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
||||
Select one or more versions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-grayScale-200 bg-[#F8FAFC] py-4 px-12">
|
||||
<button
|
||||
className="text-[14px] text-grayScale-500 transition-colors hover:text-grayScale-700"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
className="h-10 px-12 rounded-[6px] bg-[#9E2891] text-[14px] font-bold text-white shadow-lg shadow-brand-500/10 transition-all active:scale-95 flex items-center gap-3"
|
||||
>
|
||||
Next: Review
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { GraduationCap, ArrowRight, LayoutGrid, Monitor } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Select } from "../../../../components/ui/select";
|
||||
|
||||
interface ContextStepProps {
|
||||
formData: any;
|
||||
setFormData: (data: any) => void;
|
||||
nextStep: () => void;
|
||||
navigate: (path: string) => void;
|
||||
level: string;
|
||||
isModuleContext?: boolean;
|
||||
isCourseContext?: boolean;
|
||||
}
|
||||
|
||||
export function ContextStep({
|
||||
formData,
|
||||
setFormData,
|
||||
nextStep,
|
||||
navigate,
|
||||
level,
|
||||
isModuleContext,
|
||||
isCourseContext,
|
||||
}: ContextStepProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
|
||||
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
|
||||
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
|
||||
Step 1: Context Definition
|
||||
</h2>
|
||||
<p className="text-grayScale-600 text-base mt-3">
|
||||
Define the educational level and curriculum module for this practice.
|
||||
</p>
|
||||
</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 opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10 p-10">
|
||||
{/* Program Field */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[16px] text-grayScale-700 ml-1">
|
||||
Program{" "}
|
||||
<span className="text-grayScale-300 font-medium">
|
||||
(Auto-selected)
|
||||
</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
||||
<GraduationCap className="h-6 w-6 text-grayScale-600" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
|
||||
disabled
|
||||
>
|
||||
<option>{formData.program || "Intermediate"}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Field */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
||||
Course{" "}
|
||||
<span className="text-grayScale-300 font-medium">
|
||||
(Auto-selected)
|
||||
</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
||||
<GraduationCap className="h-6 w-6 text-grayScale-600" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
|
||||
disabled
|
||||
>
|
||||
<option>{formData.course || "B2"}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select Module Field */}
|
||||
{(isModuleContext || isCourseContext) && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
||||
Select Module
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
||||
<LayoutGrid className="h-6 w-6 text-grayScale-400" />
|
||||
</div>
|
||||
<Select className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all">
|
||||
<option value="">Choose a module...</option>
|
||||
<option value="m1">Introduction Basics</option>
|
||||
<option value="m2">Daily Routines</option>
|
||||
<option value="m3">Travel Essentials</option>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[13px] text-grayScale-400 font-medium px-2">
|
||||
Select the specific learning module this practice will reinforce.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Select Video Field (Conditional) */}
|
||||
{isModuleContext && (
|
||||
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
||||
Select Video
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
||||
<Monitor className="h-6 w-6 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all"
|
||||
value={formData.selectedVideo}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, selectedVideo: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose a video</option>
|
||||
<option value="v1">Intro to Greetings</option>
|
||||
<option value="v2">Advanced Grammar</option>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[13px] text-grayScale-400 font-medium px-2">
|
||||
Select the specific learning module this practice will reinforce.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
||||
<button
|
||||
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
|
||||
onClick={() =>
|
||||
navigate(`/new-content/learn-english/${level}/courses`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2"
|
||||
>
|
||||
Next: {isModuleContext ? "Persona" : "Scenario"}{" "}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Check, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../../../components/ui/avatar";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { PERSONAS } from "./constants";
|
||||
|
||||
interface PersonaStepProps {
|
||||
selectedPersona: string | null;
|
||||
setSelectedPersona: (id: string) => void;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
}
|
||||
|
||||
export function PersonaStep({
|
||||
selectedPersona,
|
||||
setSelectedPersona,
|
||||
nextStep,
|
||||
prevStep,
|
||||
}: PersonaStepProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1 px-2">
|
||||
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
||||
Select Personas
|
||||
</h2>
|
||||
<p className="text-grayScale-400 text-lg">
|
||||
Choose the characters that will participate in this practice scenario.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
||||
{PERSONAS.map((persona) => {
|
||||
const isSelected = selectedPersona === persona.id;
|
||||
return (
|
||||
<div
|
||||
key={persona.id}
|
||||
onClick={() => setSelectedPersona(persona.id)}
|
||||
className={cn(
|
||||
"group relative w-[260px] cursor-pointer rounded-2xl border-2 bg-white p-6 transition-all duration-300",
|
||||
isSelected
|
||||
? "border-brand-500"
|
||||
: "border-grayScale-100 hover:border-brand-200",
|
||||
)}
|
||||
>
|
||||
{/* Top-right checkmark badge */}
|
||||
{isSelected && (
|
||||
<div className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white z-10">
|
||||
<Check className="h-4 w-4 stroke-[3]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Avatar with conditional purple ring */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full p-[3px] transition-all duration-300",
|
||||
isSelected ? "bg-brand-500" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-24 w-24 border-2 border-white">
|
||||
<AvatarImage src={persona.avatar} />
|
||||
<AvatarFallback>
|
||||
{persona.name.substring(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-grayScale-700">
|
||||
{persona.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-8">
|
||||
<Button
|
||||
onClick={prevStep}
|
||||
variant="outline"
|
||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
className="h-10 rounded-[6px] bg-brand-500 px-8 hover:bg-brand-600 shadow-md shadow-brand-500/20"
|
||||
>
|
||||
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { Edit2, Trash2, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
interface ProgramAttachReviewStepProps {
|
||||
formData: any;
|
||||
prevStep: () => void;
|
||||
onPublish: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MOCK_QUESTIONS = [
|
||||
{
|
||||
id: "q1",
|
||||
title: "1. Speak About The Photo",
|
||||
description:
|
||||
'Passage: "Good morning, everyone. I\'d like to start by reviewing the quarterly figures. As you can see from the chart..."',
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
title: "2. Fill In the Blank",
|
||||
description:
|
||||
'Passage: "Attention passengers on Flight 492 to London. We are now inviting passengers with small children..."',
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
title: "3. Writing Part 1",
|
||||
description:
|
||||
'Passage: "In today\'s lecture on astrophysics, we will discuss the concept of event horizons and their implications..."',
|
||||
},
|
||||
];
|
||||
|
||||
export function ProgramAttachReviewStep({
|
||||
formData,
|
||||
prevStep,
|
||||
onPublish,
|
||||
onCancel,
|
||||
}: ProgramAttachReviewStepProps) {
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
{/* Questions List */}
|
||||
<div className="space-y-6">
|
||||
{MOCK_QUESTIONS.map((q) => (
|
||||
<Card
|
||||
key={q.id}
|
||||
className="group relative flex border-grayScale-200 rounded-2xl bg-white overflow-hidden transition-all"
|
||||
>
|
||||
{/* Grip Area */}
|
||||
<div className="w-[50px] flex items-center justify-center bg-white border-r border-grayScale-200">
|
||||
<GripVertical className="h-3 w-3 text-grayScale-600" />
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 p-8 py-10">
|
||||
<h4 className="text-[18px] font-medium text-[#0F172A] mb-3 leading-tight">
|
||||
{q.title}
|
||||
</h4>
|
||||
<p className="text-[14px] text-grayScale-400 leading-relaxed line-clamp-2">
|
||||
{q.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions Area (Vertical Stack) */}
|
||||
<div className="w-[50px] border-l border-grayScale-200 flex flex-col items-center justify-center divide-y divide-grayScale-50">
|
||||
<button className="flex-1 w-full flex items-center justify-center text-grayScale-600 hover:text-brand-500 transition-colors border-b border-grayScale-200">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="flex-1 w-full flex items-center justify-center text-grayScale-600 hover:text-red-500 transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex bg-[#F8FAFC] border border-grayScale-200 items-center rounded-[12px] justify-between py-4 px-6">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-grayScale-100 font-bold text-grayScale-500 bg-white hover:bg-grayScale-50 transition-all text-lg"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onPublish}
|
||||
className="h-10 px-10 rounded-[6px] bg-[#9E2891] text-[16px] font-medium text-white transition-all active:scale-95 flex items-center gap-3"
|
||||
>
|
||||
Next: Review & Publish
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// GripVertical helper
|
||||
function GripVertical({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 gap-0.5", className)}>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-1 w-1 rounded-full bg-current" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { LayoutGrid, Plus, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Select } from "../../../../components/ui/select";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
interface ProgramAttachStep1Props {
|
||||
formData: any;
|
||||
setFormData: (data: any) => void;
|
||||
nextStep: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ProgramAttachStep1({
|
||||
formData,
|
||||
setFormData,
|
||||
nextStep,
|
||||
onCancel,
|
||||
}: ProgramAttachStep1Props) {
|
||||
return (
|
||||
<Card className="overflow-hidden border-grayScale-100 rounded-[16px] bg-white shadow-sm animate-in fade-in duration-500">
|
||||
<div className="space-y-6 p-8 pb-16">
|
||||
{/* Select Program */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Select Program
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
|
||||
value={formData.program}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, program: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose Program</option>
|
||||
<option value="exams">English Proficiency Exams</option>
|
||||
<option value="skill">Skill-Based Courses</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tests (Auto Select) */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Tests{" "}
|
||||
<span className="text-grayScale-400 font-medium">
|
||||
(Auto Select)
|
||||
</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<div className="h-[56px] w-full rounded-[12px] border border-grayScale-200 bg-white flex items-center pl-16 text-grayScale-700 font-medium text-sm">
|
||||
Mock Exam 1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t border-grayScale-300" />
|
||||
|
||||
{/* Select Question Type */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Select Question Type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
|
||||
value={formData.questionType}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, questionType: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose question type</option>
|
||||
<option value="Speaking Practice">Speaking Practice</option>
|
||||
<option value="Writing Part 1">Writing Part 1</option>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
||||
Select one question type that associates with th selected video
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Set Version */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
||||
Set Version
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
||||
</div>
|
||||
<Select
|
||||
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
|
||||
value={formData.version}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, version: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Choose a version</option>
|
||||
<option value="V 1.0">V 1.0</option>
|
||||
<option value="V 2.0">V 2.0</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add More Button */}
|
||||
<button className="flex items-center gap-2 text-[#9E2891] font-medium text-[14px] group transition-all hover:translate-x-1">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[#9E2891] flex items-center justify-center">
|
||||
<Plus className="h-3 w-3" />
|
||||
</div>
|
||||
Add More
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-grayScale-200 bg-[#F8FAFC] py-4 px-12">
|
||||
<button
|
||||
className="text-[14px] text-grayScale-500 transition-colors hover:text-grayScale-700"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
className="h-10 px-12 rounded-[6px] bg-[#9E2891] text-[14px] font-bold text-white shadow-lg shadow-brand-500/10 transition-all active:scale-95 flex items-center gap-3"
|
||||
>
|
||||
Next: Review
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { GripVertical, Trash2, Plus, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { VoicePrompt } from "./VoicePrompt";
|
||||
|
||||
interface QuestionsStepProps {
|
||||
formData: any;
|
||||
setFormData: (data: any) => void;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
}
|
||||
|
||||
export function QuestionsStep({
|
||||
formData,
|
||||
setFormData,
|
||||
nextStep,
|
||||
prevStep,
|
||||
}: QuestionsStepProps) {
|
||||
const addQuestion = () => {
|
||||
const newQuestion = {
|
||||
id: `q${formData.questions.length + 1}`,
|
||||
text: "",
|
||||
type: "Speaking",
|
||||
voicePrompt: "upload_audio.mp3",
|
||||
sampleAnswer: "upload_audio.mp3",
|
||||
};
|
||||
setFormData({
|
||||
...formData,
|
||||
questions: [...formData.questions, newQuestion],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1 px-2">
|
||||
<h2 className="text-2xl font-bold text-grayScale-700">
|
||||
Create Practice Questions
|
||||
</h2>
|
||||
<p className="text-grayScale-400 text-lg">
|
||||
Define the dialogue flow and interactions for this scenario.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{formData.questions.map((q: any, i: number) => (
|
||||
<Card
|
||||
key={q.id}
|
||||
className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
|
||||
<div className="px-5 pb-7 pt-2 space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
|
||||
<span className="font-bold text-grayScale-500 text-base">
|
||||
Question {i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
||||
onClick={() => {
|
||||
const newQuestions = formData.questions.filter(
|
||||
(item: any) => item.id !== q.id,
|
||||
);
|
||||
setFormData({ ...formData, questions: newQuestions });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
||||
<div className="md:col-span-8 space-y-3">
|
||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
||||
QUESTION PROMPT
|
||||
</label>
|
||||
<Input
|
||||
value={q.text}
|
||||
onChange={(e) => {
|
||||
const newQuestions = [...formData.questions];
|
||||
newQuestions[i].text = e.target.value;
|
||||
setFormData({ ...formData, questions: newQuestions });
|
||||
}}
|
||||
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
|
||||
placeholder="e.g. How long have you been studying English?"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-4 space-y-3">
|
||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
||||
VOICE PROMPT
|
||||
</label>
|
||||
<VoicePrompt
|
||||
src={q.voicePrompt}
|
||||
filename={q.voicePrompt}
|
||||
onRemove={() => {
|
||||
const newQuestions = [...formData.questions];
|
||||
newQuestions[i].voicePrompt = "";
|
||||
setFormData({ ...formData, questions: newQuestions });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/3 space-y-3">
|
||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
||||
SAMPLE ANSWER PROMPT
|
||||
</label>
|
||||
<VoicePrompt
|
||||
src={q.sampleAnswer}
|
||||
filename={q.sampleAnswer}
|
||||
onRemove={() => {
|
||||
const newQuestions = [...formData.questions];
|
||||
newQuestions[i].sampleAnswer = "";
|
||||
setFormData({ ...formData, questions: newQuestions });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex items-center gap-8 pt-4">
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
||||
<Plus className="h-3 w-3 stroke-[4]" />
|
||||
</div>{" "}
|
||||
Add New Question
|
||||
</button>
|
||||
<button className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
||||
<Plus className="h-3 w-3 stroke-[4]" />
|
||||
</div>{" "}
|
||||
Add Tips
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-8">
|
||||
<Button
|
||||
onClick={prevStep}
|
||||
variant="outline"
|
||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold "
|
||||
>
|
||||
Next: Review <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import { Edit2, GripVertical, Trash2, Rocket, Info } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "../../../../components/ui/avatar";
|
||||
import { PERSONAS } from "./constants";
|
||||
import { VoicePrompt } from "./VoicePrompt";
|
||||
|
||||
interface ReviewStepProps {
|
||||
formData: any;
|
||||
selectedPersona: string | null;
|
||||
prevStep: () => void;
|
||||
setIsPublished: (val: boolean) => void;
|
||||
isModuleContext?: boolean;
|
||||
}
|
||||
|
||||
export function ReviewStep({
|
||||
formData,
|
||||
selectedPersona,
|
||||
prevStep,
|
||||
setIsPublished,
|
||||
isModuleContext,
|
||||
}: ReviewStepProps) {
|
||||
const persona = PERSONAS.find((p) => p.id === selectedPersona);
|
||||
|
||||
return (
|
||||
<div className="space-y-10 animate-in fade-in duration-700">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
|
||||
Review Practice Questions
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 1. Basic Info Card (Image 1436.1) */}
|
||||
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white ">
|
||||
<div className="border-b border-grayScale-50 p-4 px-5 flex justify-between items-center bg-white">
|
||||
<h3 className="text-[17px] font-extrabold text-grayScale-900">
|
||||
Basic Information
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-brand-500 font-bold hover:bg-brand-50 gap-2 h-9"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/* 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
|
||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8 px-5 flex items-center justify-between ">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="h-[70px] w-[85px] rounded-xl bg-grayScale-100 overflow-hidden shadow-inner flex-shrink-0">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
|
||||
alt="Banner"
|
||||
className="w-full h-full object-cover opacity-80"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-[22px] font-bold text-grayScale-900 leading-tight">
|
||||
{formData.title || "Business English 101: Communication"}
|
||||
</h4>
|
||||
<div className="flex items-center gap-6 text-[14px]">
|
||||
<span className="text-grayScale-900 ">
|
||||
Program:{" "}
|
||||
<span className="text-brand-500 ">{formData.program}</span>
|
||||
</span>
|
||||
<span className="text-grayScale-900 ">
|
||||
Course:{" "}
|
||||
<span className="text-brand-500 ">{formData.course}</span>
|
||||
</span>
|
||||
<span className="text-grayScale-900 font-bold">
|
||||
Module:{" "}
|
||||
<span className="text-brand-500 font-extrabold">
|
||||
Module 101
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-[11px] text-left font-medium text-grayScale-900 ">
|
||||
Persona
|
||||
</span>
|
||||
<div className="flex items-center gap-2 bg-[#FAF5FF] py-1 pl-2.5 pr-4 rounded-full border border-brand-100/30">
|
||||
<Avatar className="h-8 w-8 border-2 border-white shadow-sm font-bold">
|
||||
<AvatarImage src={persona?.avatar} />
|
||||
<AvatarFallback>P</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[14px] text-brand-500 capitalize">
|
||||
{persona?.name || "Alex Johnson"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 2. Tips Section (Image 1436.1) */}
|
||||
<div className="space-y-4 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[12px] font-bold text-grayScale-900 uppercase tracking-widest leading-none">
|
||||
TIPS / GUIDANCE
|
||||
</label>
|
||||
<Info className="h-4 w-4 text-brand-500" />
|
||||
</div>
|
||||
<div className="px-5 pt-2 pb-8 bg-white border border-[#E2E8F0] shadow-sm rounded-xl">
|
||||
<p className="text-[14px] text-grayScale-500 font-medium leading-relaxed">
|
||||
{formData.tips ||
|
||||
"Focus on using the present perfect continuous tense to describe an action that started in the past and continues now."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModuleContext ? (
|
||||
/* 3. Split Questions & Answers Layout (Image 1413.1) */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 bg-white rounded-[12px] border border-grayScale-50 shadow-sm overflow-hidden min-h-[600px]">
|
||||
{/* Left Column: Questions */}
|
||||
<div className="border-r border-grayScale-200 flex flex-col">
|
||||
<div className="p-4 border-b border-grayScale-50 flex items-center gap-3 bg-white">
|
||||
<h3 className="text-[16px] font-extrabold text-[#0F172A]">
|
||||
Questions
|
||||
</h3>
|
||||
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
|
||||
{formData.questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-14">
|
||||
{formData.questions.map((q: any, i: number) => (
|
||||
<div key={q.id} className="relative pl-12">
|
||||
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
|
||||
{(i + 1).toString().padStart(2, "0")}
|
||||
</span>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
|
||||
TEXT PROMPT
|
||||
</span>
|
||||
<p className="text-[16px] font-medium text-grayScale-600 leading-relaxed max-w-[90%]">
|
||||
{q.text}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<span className="text-[11px] font-extrabold text-grayScale-300 uppercase tracking-[0.1em] block">
|
||||
VOICE PROMPT
|
||||
</span>
|
||||
<VoicePrompt
|
||||
filename={q.voicePrompt}
|
||||
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[72px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Answers */}
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[16px] font-extrabold ">Answers</h3>
|
||||
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
|
||||
{formData.questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 text-brand-500 font-bold text-[15px] hover:opacity-80 transition-opacity">
|
||||
<Edit2 className="h-3 w-3" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-14">
|
||||
{formData.questions.map((q: any, i: number) => (
|
||||
<div key={q.id + "_ans"} className="relative pl-12">
|
||||
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
|
||||
{(i + 1).toString().padStart(2, "0")}
|
||||
</span>
|
||||
<div className="space-y-4">
|
||||
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
|
||||
VOICE PROMPT
|
||||
</span>
|
||||
<VoicePrompt
|
||||
filename={q.sampleAnswer}
|
||||
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Original Non-Module View */
|
||||
<div className="space-y-6">
|
||||
{formData.questions.map((q: any, i: number) => (
|
||||
<ReviewItem key={q.id} q={q} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Footer */}
|
||||
<div className="flex items-center justify-between pt-12">
|
||||
<Button
|
||||
onClick={prevStep}
|
||||
variant="outline"
|
||||
className="h-10 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
|
||||
>
|
||||
Save as Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsPublished(true)}
|
||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold hover:bg-brand-600 shadow-xl shadow-brand-500/20 gap-3 active:scale-95 transition-all text-white text-sm"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
Publish Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewItem({ q, index }: { q: any; index: number }) {
|
||||
return (
|
||||
<Card className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
|
||||
<div className="px-5 pb-7 pt-2 space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
|
||||
<span className="font-bold text-grayScale-500 text-base">
|
||||
Question {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
||||
<div className="md:col-span-8 space-y-3">
|
||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
||||
QUESTION PROMPT
|
||||
</label>
|
||||
<Input
|
||||
value={q.text}
|
||||
readOnly
|
||||
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
|
||||
placeholder="e.g. How long have you been studying English?"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-4 space-y-3">
|
||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
||||
VOICE PROMPT
|
||||
</label>
|
||||
<VoicePrompt
|
||||
src={q.voicePrompt}
|
||||
filename={q.voicePrompt}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/3 space-y-3">
|
||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
||||
SAMPLE ANSWER PROMPT
|
||||
</label>
|
||||
<VoicePrompt
|
||||
src={q.sampleAnswer}
|
||||
filename={q.sampleAnswer}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { Upload, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { Textarea } from "../../../../components/ui/textarea";
|
||||
|
||||
interface ScenarioStepProps {
|
||||
formData: any;
|
||||
setFormData: (data: any) => void;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
}
|
||||
|
||||
export function ScenarioStep({
|
||||
formData,
|
||||
setFormData,
|
||||
nextStep,
|
||||
prevStep,
|
||||
}: ScenarioStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1 px-2">
|
||||
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
||||
Define Scenario Details
|
||||
</h2>
|
||||
<p className="text-grayScale-400 text-lg">
|
||||
Set the scene and context for this English practice session.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-grayScale-700">
|
||||
Practice Banner Image
|
||||
</label>
|
||||
<p className="text-xs pb-2 text-grayScale-400">
|
||||
This image will appear as the background for the scenario.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-grayScale-200 bg-[#F8F9FA] p-12 hover:bg-grayScale-50 transition-all">
|
||||
<div className="mb-4 rounded-xl border border-grayScale-100 bg-white p-3 text-brand-500 shadow-sm">
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="text-grayScale-700">
|
||||
Click to upload or drag and drop
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-grayScale-400 uppercase tracking-wide ">
|
||||
SVG, PNG, JPG (MAX 5MB)
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-6 h-10 rounded-[6px] border-grayScale-200 bg-white px-8 font-bold text-brand-500 shadow-sm hover:bg-grayScale-50"
|
||||
>
|
||||
Browse Files
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Practice Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g., Ordering Coffee at a Cafe"
|
||||
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
|
||||
value={formData.title}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Scenario Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
placeholder="Describe the setting..."
|
||||
className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white"
|
||||
maxLength={1000}
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="absolute bottom-4 right-4 text-xs font-bold text-grayScale-500">
|
||||
{formData.description.length} / 1000
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-grayScale-500">
|
||||
Provide context for the AI and the student. Be specific about the
|
||||
location and the goal.
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<Button
|
||||
onClick={prevStep}
|
||||
variant="outline"
|
||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600 shadow-sm"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={!formData.title || !formData.description}
|
||||
className="h-10 rounded-[6px] bg-brand-500 px-8 "
|
||||
>
|
||||
Next: Persona <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Play, Pause, X } from "lucide-react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
interface VoicePromptProps {
|
||||
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
|
||||
src?: string;
|
||||
filename: string;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BAR_COUNT = 24;
|
||||
|
||||
export function VoicePrompt({
|
||||
src,
|
||||
filename,
|
||||
onRemove,
|
||||
className,
|
||||
}: VoicePromptProps) {
|
||||
const [bars, setBars] = useState<number[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0–1
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// ─── Decode audio and build waveform bars ───────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
// No real audio — generate plausible static bars
|
||||
setBars(generateFakeBars());
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const audioCtx = new AudioContext();
|
||||
|
||||
fetch(src)
|
||||
.then((r) => r.arrayBuffer())
|
||||
.then((buf) => audioCtx.decodeAudioData(buf))
|
||||
.then((decoded) => {
|
||||
if (cancelled) return;
|
||||
const raw = decoded.getChannelData(0);
|
||||
const blockSize = Math.floor(raw.length / BAR_COUNT);
|
||||
const barsData = Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||
let sum = 0;
|
||||
for (let j = 0; j < blockSize; j++) {
|
||||
sum += Math.abs(raw[i * blockSize + j]);
|
||||
}
|
||||
return sum / blockSize;
|
||||
});
|
||||
// Normalize to 0–1
|
||||
const max = Math.max(...barsData, 0.001);
|
||||
setBars(barsData.map((v) => v / max));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBars(generateFakeBars());
|
||||
})
|
||||
.finally(() => audioCtx.close());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
// ─── Sync progress while playing ────────────────────────────────────────────
|
||||
const startProgressLoop = () => {
|
||||
const tick = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
setProgress(el.currentTime / (el.duration || 1));
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const stopProgressLoop = () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Play / Pause ────────────────────────────────────────────────────────────
|
||||
const handlePlayPause = () => {
|
||||
if (!src) return;
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(src);
|
||||
audioRef.current.onended = () => {
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
stopProgressLoop();
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
stopProgressLoop();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audioRef.current.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
startProgressLoop();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Cleanup on unmount ──────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopProgressLoop();
|
||||
audioRef.current?.pause();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const playedBars = Math.round(progress * BAR_COUNT);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-4 py-3 bg-[#F9F0FB] rounded-6px border border-brand-100 min-h-[60px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Play / Pause button */}
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="h-8 w-8 flex-shrink-0 rounded-full bg-brand-500 flex items-center justify-center text-white shadow-md hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3 fill-current" />
|
||||
) : (
|
||||
<Play className="h-3 w-3 fill-current ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Waveform + filename */}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
{/* Bars — centered vertically, grow up and down */}
|
||||
<div className="flex items-center gap-[3.5px] h-6 overflow-hidden">
|
||||
{(bars.length ? bars : generateFakeBars()).map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[4px] rounded-full flex-shrink-0"
|
||||
style={{
|
||||
height: `${Math.max(v * 100, 14)}%`,
|
||||
backgroundColor: i < playedBars ? "#9E2891" : "#D5C5DC",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Filename */}
|
||||
<p className="text-[11px] font-semibold text-brand-500 truncate">
|
||||
{filename}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="flex-shrink-0 text-brand-500 hover:text-brand-700 transition-colors p-1"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function generateFakeBars(): number[] {
|
||||
// Realistic-looking static waveform (peaks in middle, quieter at edges)
|
||||
return Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||
const center = BAR_COUNT / 2;
|
||||
const envelope = 1 - Math.abs(i - center) / center;
|
||||
return Math.max(0.1, envelope * (0.4 + Math.random() * 0.6));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
export const PERSONAS = [
|
||||
{
|
||||
id: "dawit",
|
||||
name: "Dawit",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
|
||||
},
|
||||
{
|
||||
id: "mahlet",
|
||||
name: "Mahlet",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
|
||||
},
|
||||
{
|
||||
id: "amanuel",
|
||||
name: "Amanuel",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
|
||||
},
|
||||
{
|
||||
id: "bethel",
|
||||
name: "Bethel",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
|
||||
},
|
||||
{
|
||||
id: "liya",
|
||||
name: "Liya",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
|
||||
},
|
||||
{
|
||||
id: "aseffa",
|
||||
name: "Aseffa",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
|
||||
},
|
||||
{
|
||||
id: "hana",
|
||||
name: "Hana",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
|
||||
},
|
||||
{
|
||||
id: "nahom",
|
||||
name: "Nahom",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
|
||||
},
|
||||
];
|
||||
|
||||
export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"];
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { useState } from "react";
|
||||
import { X, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { Select } from "../../../../components/ui/select";
|
||||
import { Badge } from "../../../../components/ui/badge";
|
||||
|
||||
interface QuestionTypeBasicInfoStepProps {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function QuestionTypeBasicInfoStep({
|
||||
onNext,
|
||||
}: QuestionTypeBasicInfoStepProps) {
|
||||
const [selectedChips, setSelectedChips] = useState([
|
||||
"Multiple Choice",
|
||||
"Sentence Completion",
|
||||
]);
|
||||
const suggestions = ["Matching Headings", "True/False/NG"];
|
||||
|
||||
const removeChip = (chip: string) => {
|
||||
setSelectedChips(selectedChips.filter((c) => c !== chip));
|
||||
};
|
||||
|
||||
const addChip = (chip: string) => {
|
||||
if (!selectedChips.includes(chip)) {
|
||||
setSelectedChips([...selectedChips, chip]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-32">
|
||||
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
|
||||
<div className="p-10 border-b border-grayScale-200">
|
||||
<h2 className="text-[20px] font-medium text-grayScale-900">
|
||||
STEP 1: Basic Info
|
||||
</h2>
|
||||
<p className="text-grayScale-500 font-medium mt-1">
|
||||
Define what this question type is and where it applies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-10 space-y-10">
|
||||
{/* Top Row: Course Type & Skill Category */}
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
|
||||
Course Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
|
||||
<option>Select an exam type</option>
|
||||
<option>IELTS</option>
|
||||
<option>Duolingo</option>
|
||||
<option>TOEFL</option>
|
||||
</Select>
|
||||
<p className="text-grayScale-400 text-[13px] font-medium leading-relaxed">
|
||||
The core framework for the practice test.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
|
||||
Skill Category <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
|
||||
<option>Select a skill</option>
|
||||
<option>Speaking</option>
|
||||
<option>Writing</option>
|
||||
<option>Listening</option>
|
||||
<option>Reading</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Type */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
|
||||
Question Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
|
||||
<option>Single Format</option>
|
||||
<option>Mixed Format</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Question Types Chip Input */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
|
||||
Question Types <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="min-h-[56px] p-3 flex flex-wrap gap-2.5 rounded-[12px] border border-grayScale-300 bg-[#F8FAFC]">
|
||||
{selectedChips.map((chip) => (
|
||||
<Badge
|
||||
key={chip}
|
||||
className="bg-[#9E28911A] text-[#9E2891] border-[#9E289133] px-2 py-0 rounded-full text-[13px] font-medium flex items-center gap-2"
|
||||
>
|
||||
{chip}
|
||||
<button
|
||||
onClick={() => removeChip(chip)}
|
||||
className="hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<input
|
||||
className="flex-1 min-w-[150px] bg-transparent border-none focus:ring-0 text-[14px] font-medium text-grayScale-900 px-3 placeholder:text-grayScale-400"
|
||||
placeholder="Add question types..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<span className="text-[13px] font-medium text-grayScale-500">
|
||||
Suggestions:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => addChip(s)}
|
||||
className="px-3 py-1.5 rounded-[6px] border border-grayScale-300 text-[13px] font-medium text-grayScale-600 hover:bg-grayScale-50 hover:text-[#9E2891] hover:border-[#9E2891]/20 transition-all"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
|
||||
>
|
||||
Next: Structure
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,249 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Rocket, Edit2, Link2, Video } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import type { AddLessonFormData } from "../../AddVideoFlow";
|
||||
import {
|
||||
applyShortPreviewToEmbedUrl,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
formatPreviewLength,
|
||||
getVideoPreview,
|
||||
resolveThumbnailForPreview,
|
||||
} from "../../../../lib/videoPreview";
|
||||
import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
|
||||
|
||||
interface ReviewPublishStepProps {
|
||||
formData: AddLessonFormData;
|
||||
prevStep: () => void;
|
||||
onPublish: () => void;
|
||||
publishing: boolean;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
export function ReviewPublishStep({
|
||||
formData,
|
||||
prevStep,
|
||||
onPublish,
|
||||
publishing,
|
||||
}: ReviewPublishStepProps) {
|
||||
const [thumbBroken, setThumbBroken] = useState(false);
|
||||
const videoPreview = useMemo(
|
||||
() => getVideoPreview(formData.videoUrl),
|
||||
[formData.videoUrl],
|
||||
);
|
||||
const limitedEmbedSrc = useMemo(() => {
|
||||
if (videoPreview.kind !== "iframe") return null;
|
||||
return applyShortPreviewToEmbedUrl(
|
||||
videoPreview.src,
|
||||
videoPreview.label,
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
);
|
||||
}, [videoPreview]);
|
||||
const previewLengthLabel = formatPreviewLength(DEFAULT_PREVIEW_MAX_SECONDS);
|
||||
const thumbSrc = useMemo(
|
||||
() => resolveThumbnailForPreview(formData.thumbnailUrl),
|
||||
[formData.thumbnailUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbBroken(false);
|
||||
}, [thumbSrc]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
|
||||
<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">
|
||||
<h3 className="text-[17px] font-bold text-grayScale-900">
|
||||
Media preview
|
||||
</h3>
|
||||
<p className="text-xs font-medium text-grayScale-500">
|
||||
Video: short clip (first {previewLengthLabel} only)
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col gap-10 xl:flex-row xl:items-start xl:gap-10">
|
||||
{/* Video preview */}
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Video
|
||||
</span>
|
||||
{formData.videoUrl ? (
|
||||
<div className="space-y-3">
|
||||
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
|
||||
<div className="relative aspect-video w-full max-w-4xl">
|
||||
<iframe
|
||||
key={limitedEmbedSrc}
|
||||
src={limitedEmbedSrc}
|
||||
title={`${videoPreview.label} lesson preview`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
|
||||
Short clip · max {previewLengthLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : videoPreview.kind === "video" ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
|
||||
<PreviewLimitedFileVideo
|
||||
src={videoPreview.src}
|
||||
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-grayScale-200 bg-grayScale-50/80 px-6 py-10 text-center">
|
||||
<Video className="h-10 w-10 text-grayScale-300" />
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No inline preview for this URL
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-500 max-w-md">
|
||||
Use a Vimeo, YouTube, or direct link to a video file
|
||||
(MP4, WebM, …) to see a player here. The URL below will
|
||||
still be saved.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 text-[13px] text-grayScale-600 break-all">
|
||||
<Link2 className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-grayScale-400" />
|
||||
{truncate(formData.videoUrl, 220)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-grayScale-400 text-sm">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail preview */}
|
||||
<div className="w-full shrink-0 space-y-3 xl:max-w-[360px]">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Thumbnail
|
||||
</span>
|
||||
{formData.thumbnailUrl && thumbSrc ? (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-100 shadow-sm">
|
||||
<div className="relative aspect-video w-full max-w-md">
|
||||
{!thumbBroken ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onError={() => setThumbBroken(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-video w-full max-w-md items-center justify-center bg-grayScale-200 px-4 text-center text-xs text-grayScale-500">
|
||||
Thumbnail could not be loaded. URL will still be
|
||||
saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-grayScale-500 break-all">
|
||||
{truncate(formData.thumbnailUrl, 160)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-grayScale-400 text-sm">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
|
||||
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
||||
<h3 className="text-[16px] font-bold text-grayScale-900">
|
||||
Content details
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-10">
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Title
|
||||
</span>
|
||||
<p className="text-[15px] font-medium text-grayScale-900">
|
||||
{formData.title || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||
Description
|
||||
</span>
|
||||
<div
|
||||
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
formData.description || "<p class='text-grayScale-400'>—</p>",
|
||||
}}
|
||||
/>
|
||||
</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 opacity-20 rounded-full"
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
disabled={publishing}
|
||||
className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
||||
disabled={publishing}
|
||||
onClick={() =>
|
||||
toast.info("Drafts are not supported yet. Use Create lesson.")
|
||||
}
|
||||
>
|
||||
Save as draft
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onPublish}
|
||||
disabled={publishing}
|
||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
{publishing ? "Creating…" : "Create lesson"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { List, Link as LinkIcon, Lightbulb, ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import type { AddLessonFormData } from "../../AddVideoFlow";
|
||||
import { LessonMediaUploadField } from "../LessonMediaUploadField";
|
||||
|
||||
function isDescriptionEmpty(raw: string): boolean {
|
||||
if (!raw?.trim()) return true;
|
||||
const t = raw.replace(/<[^>]+>/g, "").replace(/ /g, " ").trim();
|
||||
return t.length === 0;
|
||||
}
|
||||
|
||||
interface VideoDetailStepProps {
|
||||
formData: AddLessonFormData;
|
||||
setFormData: Dispatch<SetStateAction<AddLessonFormData>>;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function VideoDetailStep({
|
||||
formData,
|
||||
setFormData,
|
||||
onContinue,
|
||||
}: VideoDetailStepProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const isInternalChange = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !isInternalChange.current) {
|
||||
editorRef.current.innerHTML = formData.description || "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCommand = (command: string, value?: string) => {
|
||||
document.execCommand(command, false, value);
|
||||
syncState();
|
||||
};
|
||||
|
||||
const syncState = () => {
|
||||
if (editorRef.current) {
|
||||
isInternalChange.current = true;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
description: editorRef.current!.innerHTML,
|
||||
}));
|
||||
setTimeout(() => {
|
||||
isInternalChange.current = false;
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
syncState();
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (editorRef.current) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
description: editorRef.current!.innerHTML,
|
||||
}));
|
||||
}
|
||||
if (!formData.title.trim()) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
if (!formData.videoUrl.trim()) {
|
||||
toast.error("Add a video URL or upload a video");
|
||||
return;
|
||||
}
|
||||
if (!formData.thumbnailUrl.trim()) {
|
||||
toast.error("Add a thumbnail or upload an image");
|
||||
return;
|
||||
}
|
||||
const descHtml = editorRef.current?.innerHTML ?? formData.description;
|
||||
if (isDescriptionEmpty(descHtml)) {
|
||||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
onContinue();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20">
|
||||
<div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
|
||||
Video
|
||||
</h3>
|
||||
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
|
||||
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
|
||||
sent to your storage via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<LessonMediaUploadField
|
||||
kind="video"
|
||||
value={formData.videoUrl}
|
||||
onChange={(v) =>
|
||||
setFormData((prev) => ({ ...prev, videoUrl: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 opacity-20 rounded-full"
|
||||
style={{ background: "gray" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
||||
<div className="flex-1 w-full space-y-10">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Lesson title
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Introduction to Past Tense"
|
||||
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
|
||||
value={formData.title}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||
Description
|
||||
</label>
|
||||
<div className="rounded-xl border border-grayScale-200 bg-white overflow-hidden flex flex-col min-h-[200px] shadow-sm focus-within:border-brand-200 transition-all">
|
||||
<div className="flex items-center gap-1 bg-[#F8FAFC]">
|
||||
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCommand("bold")}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif font-bold text-[17px] pb-0.5 active:bg-grayScale-50"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCommand("italic")}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif italic text-[17px] pr-0.5 active:bg-grayScale-50"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCommand("insertUnorderedList")}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = prompt("Enter URL:");
|
||||
if (url) handleCommand("createLink", url);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative p-6 flex-1">
|
||||
{isDescriptionEmpty(formData.description) && (
|
||||
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
|
||||
What will students learn in this lesson?
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={handleInput}
|
||||
className="w-full min-h-[140px] focus:outline-none text-[15px] text-grayScale-700 font-medium leading-relaxed prose prose-sm max-w-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-[360px] space-y-5">
|
||||
<LessonMediaUploadField
|
||||
kind="thumbnail"
|
||||
value={formData.thumbnailUrl}
|
||||
onChange={(v) =>
|
||||
setFormData((prev) => ({ ...prev, thumbnailUrl: v }))
|
||||
}
|
||||
/>
|
||||
<div className="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
|
||||
<Lightbulb
|
||||
className="h-4 w-4 text-brand-50"
|
||||
fill="#A855F7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative top-[-10px]">
|
||||
<h3 className="text-[14px] font-bold text-grayScale-900">
|
||||
Pro tip
|
||||
</h3>
|
||||
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
||||
Use clear titles and a thumbnail that matches the lesson. The
|
||||
lesson is created with{" "}
|
||||
<code className="rounded bg-white/80 px-1 text-[10px]">
|
||||
POST /modules/:moduleId/lessons
|
||||
</code>{" "}
|
||||
when you publish.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2 text-sm group active:scale-95"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -95,12 +95,13 @@ function getStatusConfig(status: string): {
|
|||
}
|
||||
}
|
||||
|
||||
function getIssueTypeConfig(type: string): {
|
||||
function getIssueTypeConfig(type: string | null | undefined): {
|
||||
label: string;
|
||||
classes: string;
|
||||
icon: typeof Bug;
|
||||
} {
|
||||
switch (type) {
|
||||
const t = String(type ?? "").trim();
|
||||
switch (t) {
|
||||
case "bug":
|
||||
return {
|
||||
label: "Bug",
|
||||
|
|
@ -133,7 +134,7 @@ function getIssueTypeConfig(type: string): {
|
|||
};
|
||||
default:
|
||||
return {
|
||||
label: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
|
||||
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
||||
icon: HelpCircle,
|
||||
};
|
||||
|
|
@ -173,8 +174,10 @@ function getRelativeTime(dateStr: string): string {
|
|||
return formatDate(dateStr);
|
||||
}
|
||||
|
||||
function formatRoleLabel(role: string): string {
|
||||
return role
|
||||
function formatRoleLabel(role: string | null | undefined): string {
|
||||
const r = String(role ?? "").trim();
|
||||
if (!r) return "—";
|
||||
return r
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
|
|
@ -221,8 +224,9 @@ export function IssuesPage() {
|
|||
offset: (page - 1) * pageSize,
|
||||
};
|
||||
const res = await getIssues(filters);
|
||||
setIssues(res.data.data.issues);
|
||||
setTotalCount(res.data.data.total_count);
|
||||
const payload = res.data?.data;
|
||||
setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
|
||||
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch issues:", error);
|
||||
setIssues([]);
|
||||
|
|
@ -241,7 +245,7 @@ export function IssuesPage() {
|
|||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await getIssueById(issueId);
|
||||
setSelectedIssue(res.data.data);
|
||||
setSelectedIssue(res.data?.data ?? null);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch issue detail:", error);
|
||||
} finally {
|
||||
|
|
@ -305,16 +309,15 @@ export function IssuesPage() {
|
|||
};
|
||||
|
||||
// Client-side filtering (status, type, search)
|
||||
const filteredIssues = issues.filter((issue) => {
|
||||
const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
|
||||
if (statusFilter && issue.status !== statusFilter) return false;
|
||||
if (typeFilter && issue.issue_type !== typeFilter) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
issue.subject.toLowerCase().includes(q) ||
|
||||
issue.description.toLowerCase().includes(q) ||
|
||||
issue.issue_type.toLowerCase().includes(q)
|
||||
);
|
||||
const subject = String(issue.subject ?? "").toLowerCase();
|
||||
const description = String(issue.description ?? "").toLowerCase();
|
||||
const issueType = String(issue.issue_type ?? "").toLowerCase();
|
||||
return subject.includes(q) || description.includes(q) || issueType.includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
|
@ -537,10 +540,10 @@ export function IssuesPage() {
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-grayScale-600 truncate">
|
||||
{issue.subject}
|
||||
{issue.subject?.trim() ? issue.subject : "—"}
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-400 truncate mt-0.5">
|
||||
{issue.description}
|
||||
{issue.description?.trim() ? issue.description : "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -572,6 +575,9 @@ export function IssuesPage() {
|
|||
{getStatusConfig(s).label}
|
||||
</option>
|
||||
))}
|
||||
{!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
|
||||
<option value={issue.status}>{getStatusConfig(issue.status).label}</option>
|
||||
) : null}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react"
|
||||
import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getTeamMembers } from "../../api/team.api"
|
||||
import type { TeamMember } from "../../types/team.types"
|
||||
|
|
@ -282,7 +283,7 @@ export function CreateNotificationPage() {
|
|||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -347,7 +348,7 @@ export function CreateNotificationPage() {
|
|||
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
|
||||
{recipientsLoading && (
|
||||
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
|
||||
Loading users…
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
|
||||
AlertCircle, Eye, X, Pencil, Check,
|
||||
Plus,
|
||||
Search,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
X,
|
||||
Pencil,
|
||||
Check,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
|
|
@ -12,7 +22,14 @@ import { Textarea } from "../../components/ui/textarea"
|
|||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from "../../components/ui/dialog"
|
||||
import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api"
|
||||
import {
|
||||
getRoles,
|
||||
getRoleDetail,
|
||||
getAllPermissions,
|
||||
setRolePermissions,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
} from "../../api/rbac.api"
|
||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -36,6 +53,11 @@ export function RolesListPage() {
|
|||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// Delete modal state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// Role info editing state
|
||||
const [editingRole, setEditingRole] = useState(false)
|
||||
const [editName, setEditName] = useState("")
|
||||
|
|
@ -59,27 +81,28 @@ export function RolesListPage() {
|
|||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getRoles({
|
||||
query: debouncedQuery || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
setRoles(res.data.data.roles ?? [])
|
||||
setTotal(res.data.data.total ?? 0)
|
||||
} catch {
|
||||
setError("Failed to load roles.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [debouncedQuery, page, pageSize])
|
||||
|
||||
// Fetch roles
|
||||
useEffect(() => {
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getRoles({
|
||||
query: debouncedQuery || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
})
|
||||
setRoles(res.data.data.roles ?? [])
|
||||
setTotal(res.data.data.total ?? 0)
|
||||
} catch {
|
||||
setError("Failed to load roles.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchRoles()
|
||||
}, [debouncedQuery, page, pageSize])
|
||||
}, [fetchRoles])
|
||||
|
||||
// Open role detail
|
||||
const handleViewRole = async (roleId: number) => {
|
||||
|
|
@ -97,6 +120,45 @@ export function RolesListPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleDeleteRoleClick = (role: Role) => {
|
||||
setRoleToDelete(role)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleCancelDeleteRole = () => {
|
||||
setDeleteDialogOpen(false)
|
||||
setRoleToDelete(null)
|
||||
}
|
||||
|
||||
const handleConfirmDeleteRole = async () => {
|
||||
if (!roleToDelete) return
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
const res = await deleteRole(roleToDelete.id)
|
||||
toast.success(res.data.message ?? "Role deleted successfully")
|
||||
|
||||
// Close dialogs if the deleted role is currently opened.
|
||||
if (selectedRole?.id === roleToDelete.id) {
|
||||
setDetailOpen(false)
|
||||
setSelectedRole(null)
|
||||
setEditingPermissions(false)
|
||||
setEditingRole(false)
|
||||
setPermSearch("")
|
||||
}
|
||||
|
||||
setRoleToDelete(null)
|
||||
setDeleteDialogOpen(false)
|
||||
await fetchRoles()
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
"Failed to delete role."
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter role info edit mode
|
||||
const handleEditRole = () => {
|
||||
if (!selectedRole) return
|
||||
|
|
@ -302,7 +364,7 @@ export function RolesListPage() {
|
|||
{roles.map((role) => (
|
||||
<Card
|
||||
key={role.id}
|
||||
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md"
|
||||
className="overflow-hidden border border-grayScale-100 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -312,7 +374,7 @@ export function RolesListPage() {
|
|||
: "bg-gradient-to-r from-brand-500 to-brand-600",
|
||||
)}
|
||||
/>
|
||||
<CardContent className="p-5">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
|
|
@ -330,32 +392,63 @@ export function RolesListPage() {
|
|||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3>
|
||||
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1">
|
||||
{role.description}
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-grayScale-700">
|
||||
{role.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-grayScale-500 line-clamp-2">
|
||||
{role.description?.trim() || "No description provided for this role."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{role.is_system && (
|
||||
<Badge variant="warning" className="shrink-0 text-[10px]">
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={role.is_system ? "warning" : "outline"}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{role.is_system ? "System" : "Custom"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-xl border border-grayScale-100 bg-grayScale-50/70 p-2.5 text-[11px]">
|
||||
<div>
|
||||
<p className="text-grayScale-400">Role ID</p>
|
||||
<p className="font-semibold text-grayScale-700">#{role.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Created</p>
|
||||
<p className="font-semibold text-grayScale-700">
|
||||
{new Date(role.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-grayScale-400">
|
||||
Created {new Date(role.created_at).toLocaleDateString()}
|
||||
Open details to view permissions
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => handleViewRole(role.id)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
View
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{!role.is_system && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleDeleteRoleClick(role)}
|
||||
disabled={deleteLoading}
|
||||
aria-label={`Delete role ${role.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => handleViewRole(role.id)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -689,6 +782,55 @@ export function RolesListPage() {
|
|||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete role dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDeleteDialogOpen(open)
|
||||
if (!open) handleCancelDeleteRole()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
Delete Role
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this role? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{roleToDelete && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-100 p-3">
|
||||
<p className="text-sm font-medium text-red-700">{roleToDelete.name}</p>
|
||||
<p className="text-xs text-red-500 mt-0.5">Role #{roleToDelete.id}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelDeleteRole}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={deleteLoading || !roleToDelete}
|
||||
onClick={handleConfirmDeleteRole}
|
||||
>
|
||||
{deleteLoading ? <SpinnerIcon className="h-3.5 w-3.5" /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||
{deleteLoading ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export interface GetCoursesResponse {
|
|||
|
||||
export interface CreateCourseRequest {
|
||||
category_id: number
|
||||
sub_category_id?: number | null
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
|
@ -56,6 +57,268 @@ export interface UpdateCourseRequest {
|
|||
is_active?: boolean
|
||||
}
|
||||
|
||||
/** Row from GET /programs (e.g. Beginner / Intermediate program buckets) */
|
||||
export interface LearningProgramListItem {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UpdateLearningProgramRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface CreateLearningProgramRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface CreateLearningProgramResponse {
|
||||
message: string
|
||||
data: LearningProgramListItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export interface GetLearningProgramsResponse {
|
||||
message: string
|
||||
data: {
|
||||
programs: LearningProgramListItem[]
|
||||
total_count: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /programs/:program_id/courses */
|
||||
export interface ProgramCourseListItem {
|
||||
id: number
|
||||
program_id: number
|
||||
name: string
|
||||
description: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
thumbnail?: string | null
|
||||
/** Some list endpoints may expose the image as `thumbnail_url` instead. */
|
||||
thumbnail_url?: string | null
|
||||
/** GET /programs/:id/courses aggregates. */
|
||||
module_count?: number
|
||||
lesson_count?: number
|
||||
practice_count?: number
|
||||
/** Legacy aggregate field names; prefer module_count, lesson_count, practice_count. */
|
||||
modules_count?: number
|
||||
videos_count?: number
|
||||
practices_count?: number
|
||||
}
|
||||
|
||||
/** Body for PUT /courses/:id (program-linked Learn English courses). */
|
||||
export interface UpdateTopLevelCourseRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
/** Body for POST /programs/:program_id/courses */
|
||||
export interface CreateProgramCourseRequest {
|
||||
name: string
|
||||
description: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export interface CreateProgramCourseResponse {
|
||||
message: string
|
||||
data: ProgramCourseListItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export interface GetProgramCoursesResponse {
|
||||
message: string
|
||||
data: {
|
||||
total_count: number
|
||||
limit: number
|
||||
offset: number
|
||||
courses: ProgramCourseListItem[]
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Row from GET /courses/:courseId/modules (Learn English track). */
|
||||
export interface TopLevelCourseModuleItem {
|
||||
id: number
|
||||
program_id: number
|
||||
course_id: number
|
||||
name: string
|
||||
description: string
|
||||
icon?: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GetTopLevelCourseModulesResponse {
|
||||
message: string
|
||||
data: {
|
||||
limit: number
|
||||
offset: number
|
||||
modules: TopLevelCourseModuleItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Body for PUT /modules/:id (Learn English top-level modules). */
|
||||
export interface UpdateTopLevelCourseModuleRequest {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** Body for POST /courses/:courseId/modules */
|
||||
export interface CreateTopLevelCourseModuleRequest {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface CreateTopLevelCourseModuleResponse {
|
||||
message: string
|
||||
data: TopLevelCourseModuleItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Row from GET /modules/:moduleId/lessons (Learn English top-level module lessons). */
|
||||
export interface TopLevelModuleLessonItem {
|
||||
id: number
|
||||
module_id: number
|
||||
title: string
|
||||
video_url: string
|
||||
thumbnail: string
|
||||
description: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GetTopLevelModuleLessonsResponse {
|
||||
message: string
|
||||
data: {
|
||||
total_count: number
|
||||
limit: number
|
||||
offset: number
|
||||
lessons: TopLevelModuleLessonItem[]
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Practice returned by GET /courses|modules|lessons/.../practices (Learn English parent-linked practice). */
|
||||
export interface ParentContextPractice {
|
||||
id: number
|
||||
parent_kind: string
|
||||
parent_id: number
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
question_set_id: number
|
||||
quick_tips: string
|
||||
persona_id?: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GetPracticesByParentContextResponse {
|
||||
message: string
|
||||
data: {
|
||||
offset: number
|
||||
limit: number
|
||||
practices: ParentContextPractice[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
|
||||
|
||||
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
|
||||
export interface CreateParentLinkedPracticeRequest {
|
||||
parent_kind: PracticeParentKind
|
||||
parent_id: number
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
question_set_id: number
|
||||
quick_tips: string
|
||||
persona_id?: number
|
||||
}
|
||||
|
||||
export interface CreateParentLinkedPracticeResponse {
|
||||
message: string
|
||||
data: ParentContextPractice
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
|
||||
export interface UpdateParentLinkedPracticeRequest {
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
question_set_id: number
|
||||
quick_tips: string
|
||||
persona_id?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateParentLinkedPracticeResponse {
|
||||
message: string
|
||||
data: ParentContextPractice
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
|
||||
export interface UpdateTopLevelModuleLessonRequest {
|
||||
title: string
|
||||
video_url: string
|
||||
thumbnail: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Body for POST /modules/:moduleId/lessons. */
|
||||
export interface CreateTopLevelModuleLessonRequest {
|
||||
title: string
|
||||
video_url: string
|
||||
thumbnail: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CreateTopLevelModuleLessonResponse {
|
||||
message: string
|
||||
data: TopLevelModuleLessonItem
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Legacy Types (deprecated - using SubCourse hierarchy now)
|
||||
// Keeping for backward compatibility with existing API endpoints
|
||||
|
|
@ -172,7 +435,13 @@ export interface GetModulesResponse {
|
|||
export interface CreateModuleRequest {
|
||||
level_id: number
|
||||
title: string
|
||||
content: string
|
||||
/** Legacy field kept for backward compatibility. */
|
||||
content?: string
|
||||
/** Preferred field for module detail text. */
|
||||
description?: string
|
||||
icon_url?: string
|
||||
display_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Use UpdateSubCourseRequest instead */
|
||||
|
|
@ -192,6 +461,8 @@ export interface UpdateModuleStatusRequest {
|
|||
export interface SubCourse {
|
||||
id: number
|
||||
course_id: number
|
||||
/** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
|
||||
level_id?: number
|
||||
module_id?: number
|
||||
title: string
|
||||
description: string
|
||||
|
|
@ -701,6 +972,72 @@ export interface HumanLanguageLesson {
|
|||
practices: LearningPathPractice[]
|
||||
}
|
||||
|
||||
export interface SubModuleLessonDetail {
|
||||
id: number
|
||||
sub_module_id: number
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
teaching_text?: string | null
|
||||
teaching_image_url?: string | null
|
||||
teaching_audio_url?: string | null
|
||||
teaching_video_url?: string | null
|
||||
}
|
||||
|
||||
export interface SubModuleLesson {
|
||||
id: number
|
||||
sub_module_id: number
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
teaching_text?: string | null
|
||||
teaching_image_url?: string | null
|
||||
teaching_audio_url?: string | null
|
||||
teaching_video_url?: string | null
|
||||
}
|
||||
|
||||
export interface GetSubModuleLessonDetailResponse {
|
||||
message: string
|
||||
data: SubModuleLessonDetail
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface UpdateSubModuleLessonRequest {
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
teaching_text?: string | null
|
||||
teaching_image_url?: string | null
|
||||
teaching_audio_url?: string | null
|
||||
teaching_video_url?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSubModuleLessonResponse {
|
||||
message: string
|
||||
data: SubModuleLessonDetail
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetSubModuleLessonsResponse {
|
||||
message: string
|
||||
data: SubModuleLesson[]
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageLessonsResponse {
|
||||
message: string
|
||||
data: {
|
||||
|
|
@ -714,10 +1051,209 @@ export interface GetHumanLanguageLessonsResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/human-language/sub-categories */
|
||||
export interface HumanLanguageSubCategoryListItem {
|
||||
id: number
|
||||
category_id: number
|
||||
category_name: string
|
||||
name: string
|
||||
description?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
/** Present on some payloads; ignore if unused. */
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageSubCategoriesResponse {
|
||||
message: string
|
||||
data: {
|
||||
sub_categories: HumanLanguageSubCategoryListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/categories/:categoryId/sub-categories */
|
||||
export interface CategorySubCategoryListItem {
|
||||
id: number
|
||||
category_id: number
|
||||
category_name: string
|
||||
name: string
|
||||
description?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
/** Sometimes echoed per row by the API; safe to ignore. */
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetCategorySubCategoriesResponse {
|
||||
message: string
|
||||
data: {
|
||||
sub_categories: CategorySubCategoryListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/sub-categories/:subCategoryId/courses */
|
||||
export interface SubCategoryCourseListItem {
|
||||
id: number
|
||||
category_id: number
|
||||
sub_category_id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
intro_video_url?: string | null
|
||||
is_active: boolean
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetSubCategoryCoursesResponse {
|
||||
message: string
|
||||
data: {
|
||||
courses: SubCategoryCourseListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/courses/:courseId/levels or GET /course-management/levels */
|
||||
export interface CourseLevelRow {
|
||||
id: number
|
||||
course_id: number
|
||||
cefr_level: string
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
title: string
|
||||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetCourseLevelsForCourseResponse {
|
||||
message: string
|
||||
data: {
|
||||
levels: CourseLevelRow[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetCourseLevelsAllResponse {
|
||||
message: string
|
||||
data: {
|
||||
levels: CourseLevelRow[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetCourseLevelByIdResponse {
|
||||
message: string
|
||||
data: CourseLevelRow
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/modules/:moduleId/sub-modules */
|
||||
export interface CourseSubModuleListItem {
|
||||
id: number
|
||||
module_id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
display_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
legacy_sub_course_id?: number | null
|
||||
thumbnail?: string | null
|
||||
tips?: string | null
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
export interface GetSubModulesByModuleResponse {
|
||||
message: string
|
||||
data: {
|
||||
sub_modules: CourseSubModuleListItem[]
|
||||
total_count: number
|
||||
}
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/human-language/hierarchy */
|
||||
export interface HumanLanguageHierarchyFlatRow {
|
||||
category_id: number
|
||||
category_name: string
|
||||
sub_category_id?: number | null
|
||||
sub_category_name?: string | null
|
||||
course_id?: number | null
|
||||
course_title?: string | null
|
||||
}
|
||||
|
||||
export interface GetHumanLanguageHierarchyFlatResponse {
|
||||
message: string
|
||||
data: HumanLanguageHierarchyFlatRow[]
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
/** Row from GET /course-management/courses/:courseId/hierarchy */
|
||||
export interface CourseHierarchyRow {
|
||||
course_id: number
|
||||
course_title: string
|
||||
level_id?: number | null
|
||||
cefr_level?: string | null
|
||||
level_title?: string | null
|
||||
level_description?: string | null
|
||||
level_thumbnail?: string | null
|
||||
module_id?: number | null
|
||||
module_title?: string | null
|
||||
module_icon_url?: string | null
|
||||
sub_module_id?: number | null
|
||||
sub_module_title?: string | null
|
||||
sub_module_description?: string | null
|
||||
sub_module_thumbnail?: string | null
|
||||
sub_module_tips?: string | null
|
||||
sub_module_display_order?: number | null
|
||||
}
|
||||
|
||||
export interface GetCourseHierarchyResponse {
|
||||
message: string
|
||||
data: CourseHierarchyRow[]
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface HumanLanguageSubModule {
|
||||
id: number
|
||||
title: string
|
||||
videos: LearningPathVideo[]
|
||||
lessons?: {
|
||||
id: number
|
||||
question_set_id: number
|
||||
title: string
|
||||
status: string
|
||||
question_count: number
|
||||
display_order: number
|
||||
intro_video_url?: string | null
|
||||
}[]
|
||||
practices: LearningPathPractice[]
|
||||
}
|
||||
|
||||
|
|
@ -728,6 +1264,7 @@ export interface HumanLanguageModule {
|
|||
}
|
||||
|
||||
export interface HumanLanguageLevelTree {
|
||||
level_id?: number
|
||||
level: string
|
||||
modules: HumanLanguageModule[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export interface CreateRoleResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface DeleteRoleResponse {
|
||||
message: string
|
||||
success: boolean
|
||||
status_code: number
|
||||
// Some backends may include extra fields; keep it optional for compatibility.
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export interface SetRolePermissionsRequest {
|
||||
permission_ids: number[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user