Compare commits

..

No commits in common. "aa998e55990ba5612621a3b53b02c19dccae465f" and "7308d9bbcdbdd675f688da6fbc4e1511359ec844" have entirely different histories.

57 changed files with 6719 additions and 15619 deletions

View File

@ -1,479 +0,0 @@
# Course Management API Integration Guide
This document describes the Course Management related APIs used by the admin frontend (`Yimaru-Admin`) and how to integrate them safely.
It is based on:
- `src/api/courses.api.ts`
- `src/api/files.api.ts`
- `src/types/course.types.ts`
- `src/api/http.ts`
---
## 1) Base setup and auth behavior
### Base URL
- All requests use `VITE_API_BASE_URL` from environment.
### Authentication
- Access token is sent automatically as `Authorization: Bearer <access_token>`.
- On `401`, the frontend attempts token refresh via:
- `POST /auth/refresh`
- payload: `{ access_token, refresh_token, role, member_id }`
- If refresh fails, auth data is cleared and user is redirected to `/login`.
### Transport notes
- Axios automatically handles `multipart/form-data` boundaries for file upload.
- Any network failure without response also redirects to `/login` (current client behavior).
---
## 2) Core domain model used by frontend
Current hierarchy used by content management:
- `Category`
- `Sub-category`
- `Course`
- `Level (CEFR)`
- `Module`
- `Sub-module`
- `Videos`
- `Lessons` (question sets with `set_type = QUIZ`)
- `Practices` (question sets with `set_type = PRACTICE`)
Important migration note:
- Some APIs/types are marked as legacy (`Program`, old `Level/Module` flows).
- Current frontend mostly uses unified hierarchy endpoints under `/course-management/...` plus `/question-sets` and `/questions`.
---
## 3) File/media APIs (used by course management)
## 3.1 Upload media
### Endpoint
- `POST /files/upload`
### Supports
- `media_type`: `"image" | "audio" | "video"`
- File upload via multipart (`file`) or URL import via JSON (`source_url`).
### For video uploads
- Can send optional `title` and `description`.
### Typical response fields used by frontend
- `data.object_key`
- `data.url`
- `data.provider` (`MINIO` or `VIMEO`)
- `data.vimeo_id`
- `data.embed_url`
### Frontend wrapper functions
- `uploadAudioFile(fileOrUrl)`
- `uploadImageFile(fileOrUrl)`
- `uploadVideoFile(fileOrUrl, { title?, description? })`
## 3.2 Resolve object key to URL
### Endpoint
- `GET /files/url?key=<object_key>`
### Use case
- Resolve media object key when only key is stored.
---
## 4) Category and course APIs
## 4.1 Get categories (normalized in frontend)
### Endpoint called
- `GET /course-management/hierarchy`
### Frontend behavior
- Client transforms flat hierarchy rows into category list.
- Duplicated category names are merged client-side by "richest" record.
### Wrapper
- `getCourseCategories()`
## 4.2 Create category or sub-category
### Category
- `POST /course-management/categories`
- body: `{ name }`
### Sub-category
- `POST /course-management/sub-categories`
- body: `{ category_id, name }`
### Wrapper
- `createCourseCategory({ name, parent_id? })`
- if `parent_id` exists, creates sub-category; else category.
## 4.3 Delete category/sub-category
- `DELETE /course-management/categories/:categoryId`
- `DELETE /course-management/sub-categories/:subCategoryId`
Wrappers:
- `deleteCourseCategory(categoryId)`
- `deleteCourseSubCategory(subCategoryId)`
## 4.4 Courses by category
### Endpoint called
- `GET /course-management/hierarchy`
### Frontend behavior
- Filters and maps rows to courses client-side.
- If duplicate category names exist, it includes rows matching requested category name.
Wrapper:
- `getCoursesByCategory(categoryId)`
## 4.5 Course CRUD
- `POST /course-management/courses`
- `PUT /course-management/courses/:courseId`
- `PUT /course-management/courses/:courseId` (status toggle via `is_active`)
- `DELETE /course-management/courses/:courseId`
- `POST /course-management/courses/:courseId/thumbnail`
Wrappers:
- `createCourse(data)`
- `updateCourse(courseId, data)`
- `updateCourseStatus(courseId, isActive)`
- `deleteCourse(courseId)`
- `updateCourseThumbnail(courseId, thumbnailUrl)`
---
## 5) Course hierarchy (levels/modules/sub-modules)
## 5.1 Get full hierarchy for one course
### Endpoint
- `GET /course-management/courses/:courseId/hierarchy`
### Wrapper
- `getSubModulesByCourse(courseId)`
### Frontend behavior
- Maps hierarchy rows into `sub_courses` shape (compatibility naming).
- This is the primary source for module/sub-module tree rendering.
## 5.2 Create sub-module flow (composed)
`createSubModule(data)` is a multi-step client workflow:
1. `POST /course-management/levels`
2. `POST /course-management/modules`
3. `POST /course-management/sub-modules`
Use this when creating a new sub-module from minimal info.
## 5.3 Direct level/module/sub-module creation
- `createModuleInLevel(levelId, title, description, displayOrder?)`
- `POST /course-management/modules`
- `createSubModuleInModule(moduleId, title, description, displayOrder?)`
- `POST /course-management/sub-modules`
## 5.4 Update/delete sub-module
- `PUT /course-management/sub-modules/:subModuleId`
- `PUT /course-management/sub-modules/:subModuleId` (status payload)
- `DELETE /course-management/sub-modules/:subModuleId`
- `POST /course-management/sub-courses/:subModuleId/thumbnail` (compat endpoint)
Wrappers:
- `updateSubModule(...)`
- `updateSubModuleStatus(...)`
- `deleteSubModule(...)`
- `updateSubModuleThumbnail(...)`
---
## 6) Video APIs (sub-module videos)
## 6.1 List videos for sub-module
- `GET /course-management/sub-modules/:subModuleId/videos`
- wrapper: `getVideosBySubModule(subModuleId)`
## 6.2 Create video
Two wrapper variants, same endpoint:
- `POST /course-management/sub-module-videos`
### Minimal variant
- `createSubCourseVideo({ sub_module_id|sub_course_id, title, description, video_url })`
### Extended variant
- `createCourseVideo({ sub_module_id|sub_course_id, title, description, video_url, duration, resolution?, visibility?, display_order?, status? })`
## 6.3 Update/delete video
- `PUT /course-management/sub-module-videos/:videoId`
- `DELETE /course-management/sub-module-videos/:videoId`
Wrappers:
- `updateSubCourseVideo(videoId, data)`
- `deleteSubCourseVideo(videoId)`
---
## 7) Practices and lessons
## 7.1 Practices by sub-module
- `GET /question-sets/by-owner?owner_type=SUB_MODULE&owner_id=:subModuleId`
- wrapper: `getPracticesBySubModule(subModuleId)`
## 7.2 Create practice (composed)
`createPractice(data)` does:
1. `POST /question-sets`
- `set_type: "PRACTICE"`
- `owner_type: "SUB_MODULE"`
- `owner_id: sub_module_id`
2. If step 1 succeeds, links to sub-module practice:
- `POST /course-management/sub-module-practices`
- includes `question_set_id` and intro metadata
## 7.3 Create lesson (composed)
`createLesson(data)` does:
1. `POST /question-sets`
- `set_type: "QUIZ"`
- `owner_type: "SUB_MODULE"`
2. Link question set as lesson:
- `POST /course-management/sub-module-lessons`
## 7.4 Practice update/delete/status
- `PUT /course-management/practices/:practiceId`
- `PUT /course-management/practices/:practiceId` (status)
- `DELETE /course-management/practices/:practiceId`
Wrappers:
- `updatePractice(...)`
- `updatePracticeStatus(...)`
- `deletePractice(...)`
---
## 8) Question sets and questions
## 8.1 Question sets
- `GET /question-sets` with optional query params
- `GET /question-sets/by-owner`
- `GET /question-sets/:id`
- `PUT /question-sets/:id`
- `DELETE /question-sets/:id`
- `POST /question-sets`
Wrappers:
- `getQuestionSets(params?)`
- `getQuestionSetsByOwner(ownerType, ownerId)`
- `getQuestionSetById(questionSetId)`
- `createQuestionSet(data)`
- `updateQuestionSet(questionSetId, partialData)`
- `deleteQuestionSet(questionSetId)`
## 8.2 Question list within set
- `GET /question-sets/:questionSetId/questions`
- `POST /question-sets/:questionSetId/questions` (add by question id)
Wrappers:
- `getQuestionSetQuestions(questionSetId)`
- `addQuestionToSet(questionSetId, { question_id, display_order? })`
## 8.3 Questions CRUD
- `GET /questions` (filters)
- `GET /questions/:questionId`
- `POST /questions`
- `PUT /questions/:questionId`
- `DELETE /questions/:questionId`
Wrappers:
- `getQuestions(params)`
- `getQuestionById(questionId)`
- `createQuestion(data)`
- `updateQuestion(questionId, data)`
- `deleteQuestion(questionId)`
## 8.4 Practice-question convenience wrappers
`createPracticeQuestion(data)`:
1. Creates question via `POST /questions`
2. Adds it to practice set via `POST /question-sets/:practiceId/questions`
`updatePracticeQuestion(questionId, data)`:
- maps to `PUT /questions/:questionId`
`deletePracticeQuestion(questionId)`:
- `DELETE /questions/:questionId`
## 8.5 Practice question listing endpoint variants
- `getPracticeQuestions(practiceId)` -> `GET /question-sets/:practiceId/questions`
- `getPracticeQuestionsByPractice(practiceId, params)` -> `GET /practices/:practiceId/questions`
Use the second when you need pagination/filtering by question type.
---
## 9) Human language specific APIs
## 9.1 Human language hierarchy
- `getHumanLanguageHierarchy()`
- Calls `GET /course-management/hierarchy`
- If backend already returns nested `sub_categories`, uses it directly.
- If backend returns flat rows, client builds nested structure and enriches each course by:
- requesting `/course-management/courses/:courseId/hierarchy`
- requesting `/question-sets/by-owner` per sub-module
- deriving lessons from question sets where `set_type = "QUIZ"`
This method is heavier than basic endpoints and can issue many requests.
## 9.2 Human language lessons by course+level
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=...`
- wrapper: `getHumanLanguageLessonsByCourse(courseId, cefrLevel)`
## 9.3 Create human language lesson structure
`createHumanLanguageLesson(data)` is composed:
1. `POST /course-management/levels`
2. `POST /course-management/modules`
3. `POST /course-management/sub-modules`
---
## 10) Learning path and assessments
- `GET /course-management/courses/:courseId/learning-path`
- wrapper: `getLearningPath(courseId)`
- `GET /question-sets/sub-courses/:subModuleId/entry-assessment`
- wrapper: `getSubModuleEntryAssessment(subModuleId)`
---
## 11) Unsupported or stubbed features in current frontend API layer
The following wrappers are intentionally stubbed in frontend and return resolved promises (no real backend call):
- `getSubModulePrerequisites`
- `addSubModulePrerequisite`
- `removeSubModulePrerequisite`
- `reorderCategories`
- `reorderCourses`
- `reorderSubModules`
- `reorderVideos`
- `reorderPractices`
Implication:
- UI may appear to support these flows, but persistence is not implemented through backend yet.
---
## 12) Legacy endpoints still exposed (backward compatibility)
These are still present in `courses.api.ts` but marked deprecated in types:
- Programs APIs
- Old levels APIs
- Old modules APIs
- Practices by level/module APIs
Prefer unified hierarchy/sub-module/question-set APIs for new work.
---
## 13) Integration patterns and recommendations
## 13.1 Safe creation flows
- For practice/lesson creation, keep composed behavior:
- create question set first
- then link to sub-module entity
- Handle partial failure:
- if link step fails after question set creation, frontend should show recoverable error and optionally support manual relink.
## 13.2 Request normalization
- `getQuestionSetsResponse.data` can be either:
- raw array
- object with `question_sets`
- Normalize before rendering.
## 13.3 Question type mapping
- UI uses `"SHORT"`; backend commonly expects `"SHORT_ANSWER"`.
- Existing wrappers already map `"SHORT"` to `"SHORT_ANSWER"` on create/update practice question.
## 13.4 Media handling
- Prefer using `/files/upload` wrappers for all media.
- For Vimeo-backed responses, frontend typically consumes `embed_url` (and may append hash from page URL where applicable).
## 13.5 Retry behavior
- Some hierarchy fetches use single retry (`withSingleRetry`) for resiliency against transient auth/network race conditions.
---
## 14) Quick endpoint index
### Course management
- `GET /course-management/hierarchy`
- `POST /course-management/categories`
- `POST /course-management/sub-categories`
- `DELETE /course-management/categories/:id`
- `DELETE /course-management/sub-categories/:id`
- `POST /course-management/courses`
- `PUT /course-management/courses/:id`
- `DELETE /course-management/courses/:id`
- `POST /course-management/courses/:id/thumbnail`
- `GET /course-management/courses/:courseId/hierarchy`
- `POST /course-management/levels`
- `POST /course-management/modules`
- `PUT /course-management/levels/:id`
- `DELETE /course-management/levels/:id`
- `PUT /course-management/modules/:id`
- `DELETE /course-management/modules/:id`
- `POST /course-management/sub-modules`
- `PUT /course-management/sub-modules/:id`
- `DELETE /course-management/sub-modules/:id`
- `GET /course-management/sub-modules/:subModuleId/videos`
- `POST /course-management/sub-module-videos`
- `PUT /course-management/sub-module-videos/:id`
- `DELETE /course-management/sub-module-videos/:id`
- `POST /course-management/sub-module-practices`
- `POST /course-management/sub-module-lessons`
- `GET /course-management/courses/:courseId/learning-path`
- `GET /course-management/human-language/courses/:courseId/lessons`
### Question sets and questions
- `GET /question-sets`
- `GET /question-sets/by-owner`
- `GET /question-sets/:id`
- `POST /question-sets`
- `PUT /question-sets/:id`
- `DELETE /question-sets/:id`
- `GET /question-sets/:id/questions`
- `POST /question-sets/:id/questions`
- `GET /practices/:practiceId/questions`
- `GET /questions`
- `GET /questions/:id`
- `POST /questions`
- `PUT /questions/:id`
- `DELETE /questions/:id`
- `POST /questions/audio-answer`
### File/media
- `POST /files/upload`
- `GET /files/url`
- `GET /vimeo/sample`
- `POST /vimeo/uploads/pull`
---
## 15) Suggested frontend service contract shape
For any new frontend module, follow this contract:
- **Input DTOs**: UI-friendly types (can include UI aliases like `SHORT`)
- **Mapper layer**: convert UI DTOs to backend DTOs
- **Transport layer**: pure API calls
- **Normalizer layer**: normalize polymorphic responses (`array` vs `object`)
- **Error policy**:
- show user-actionable toast
- preserve enough context to retry failed composed steps
This keeps integration robust even with mixed legacy/unified backend surfaces.

View File

@ -44,69 +44,18 @@ 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,
CreateExamPrepCatalogCourseRequest,
CreateExamPrepCatalogCourseResponse,
GetExamPrepCatalogCoursesResponse,
UpdateExamPrepCatalogCourseRequest,
UpdateExamPrepCatalogCourseResponse,
CreateExamPrepCatalogUnitRequest,
CreateExamPrepCatalogUnitResponse,
UpdateExamPrepCatalogUnitRequest,
UpdateExamPrepCatalogUnitResponse,
GetExamPrepCatalogUnitsResponse,
CreateExamPrepUnitModuleRequest,
CreateExamPrepUnitModuleResponse,
UpdateExamPrepUnitModuleRequest,
UpdateExamPrepUnitModuleResponse,
GetExamPrepUnitModulesResponse,
CreateExamPrepModuleLessonRequest,
CreateExamPrepModuleLessonResponse,
UpdateExamPrepModuleLessonRequest,
UpdateExamPrepModuleLessonResponse,
GetExamPrepModuleLessonsResponse,
GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse,
CreateParentLinkedPracticeRequest,
CreateParentLinkedPracticeResponse,
UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse,
UpdateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse,
} from "../types/course.types"
type UnifiedHierarchyRow = {
@ -161,35 +110,6 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name })
: http.post("/course-management/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 ?? []
@ -228,13 +148,9 @@ 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 raw = res.data?.data
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
const rows: CourseHierarchyRow[] = res.data?.data ?? []
const subModuleMap = new Map<number, { id: number; course_id: number; module_id?: number; title: string; description: string; level: string; cefr_level?: string; thumbnail: string; display_order: number; sub_level?: string; is_active: boolean }>()
rows.forEach((r, idx) => {
if (!r.sub_module_id) return
@ -309,27 +225,6 @@ 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,
@ -450,248 +345,6 @@ 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)
/** English proficiency catalog course — POST /exam-prep/catalog-courses */
export const createExamPrepCatalogCourse = (
data: CreateExamPrepCatalogCourseRequest,
) => http.post<CreateExamPrepCatalogCourseResponse>("/exam-prep/catalog-courses", data)
/** English proficiency catalog courses — GET /exam-prep/catalog-courses */
export const getExamPrepCatalogCourses = (params?: { limit?: number; offset?: number }) =>
http.get<GetExamPrepCatalogCoursesResponse>("/exam-prep/catalog-courses", { params })
/** English proficiency catalog course — PUT /exam-prep/catalog-courses/:catalogCourseId */
export const updateExamPrepCatalogCourse = (
catalogCourseId: number,
data: UpdateExamPrepCatalogCourseRequest,
) =>
http.put<UpdateExamPrepCatalogCourseResponse>(
`/exam-prep/catalog-courses/${catalogCourseId}`,
data,
)
/** English proficiency catalog course — DELETE /exam-prep/catalog-courses/:catalogCourseId */
export const deleteExamPrepCatalogCourse = (catalogCourseId: number) =>
http.delete(`/exam-prep/catalog-courses/${catalogCourseId}`)
/** English proficiency catalog unit — POST /exam-prep/catalog-courses/:catalogCourseId/units */
export const createExamPrepCatalogUnit = (
catalogCourseId: number,
data: CreateExamPrepCatalogUnitRequest,
) =>
http.post<CreateExamPrepCatalogUnitResponse>(
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
data,
)
/** English proficiency catalog units — GET /exam-prep/catalog-courses/:catalogCourseId/units */
export const getExamPrepCatalogUnits = (
catalogCourseId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetExamPrepCatalogUnitsResponse>(
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
{ params },
)
/** English proficiency unit — PUT /exam-prep/units/:unitId */
export const updateExamPrepCatalogUnit = (
unitId: number,
data: UpdateExamPrepCatalogUnitRequest,
) => http.put<UpdateExamPrepCatalogUnitResponse>(`/exam-prep/units/${unitId}`, data)
/** English proficiency unit — DELETE /exam-prep/units/:unitId */
export const deleteExamPrepCatalogUnit = (unitId: number) =>
http.delete(`/exam-prep/units/${unitId}`)
/** English proficiency unit modules — POST /exam-prep/units/:unitId/modules */
export const createExamPrepUnitModule = (
unitId: number,
data: CreateExamPrepUnitModuleRequest,
) =>
http.post<CreateExamPrepUnitModuleResponse>(
`/exam-prep/units/${unitId}/modules`,
data,
)
/** English proficiency unit modules — GET /exam-prep/units/:unitId/modules */
export const getExamPrepUnitModules = (
unitId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetExamPrepUnitModulesResponse>(`/exam-prep/units/${unitId}/modules`, {
params,
})
/** English proficiency module — PUT /exam-prep/modules/:moduleId */
export const updateExamPrepUnitModule = (
moduleId: number,
data: UpdateExamPrepUnitModuleRequest,
) =>
http.put<UpdateExamPrepUnitModuleResponse>(
`/exam-prep/modules/${moduleId}`,
data,
)
/** English proficiency module — DELETE /exam-prep/modules/:moduleId */
export const deleteExamPrepUnitModule = (moduleId: number) =>
http.delete(`/exam-prep/modules/${moduleId}`)
/** English proficiency module lessons — GET /exam-prep/modules/:moduleId/lessons */
export const getExamPrepModuleLessons = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetExamPrepModuleLessonsResponse>(
`/exam-prep/modules/${moduleId}/lessons`,
{
params,
},
)
/** English proficiency module lesson — POST /exam-prep/modules/:moduleId/lessons */
export const createExamPrepModuleLesson = (
moduleId: number,
data: CreateExamPrepModuleLessonRequest,
) =>
http.post<CreateExamPrepModuleLessonResponse>(
`/exam-prep/modules/${moduleId}/lessons`,
data,
)
/** English proficiency lesson — PUT /exam-prep/lessons/:lessonId */
export const updateExamPrepModuleLesson = (
lessonId: number,
data: UpdateExamPrepModuleLessonRequest,
) =>
http.put<UpdateExamPrepModuleLessonResponse>(
`/exam-prep/lessons/${lessonId}`,
data,
)
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
export const deleteExamPrepModuleLesson = (lessonId: number) =>
http.delete(`/exam-prep/lessons/${lessonId}`)
/** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data)
export const deleteTopLevelCourse = (courseId: number) =>
http.delete(`/courses/${courseId}`)
export const getTopLevelCourseModules = (
courseId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetTopLevelCourseModulesResponse>(`/courses/${courseId}/modules`, {
params,
})
/** Learn English top-level module — POST /courses/:courseId/modules */
export const createTopLevelCourseModule = (
courseId: number,
data: CreateTopLevelCourseModuleRequest,
) =>
http.post<CreateTopLevelCourseModuleResponse>(
`/courses/${courseId}/modules`,
data,
)
/** Learn English top-level module — PUT /modules/:id */
export const updateTopLevelCourseModule = (
moduleId: number,
data: UpdateTopLevelCourseModuleRequest,
) => http.put(`/modules/${moduleId}`, data)
/** Learn English top-level module — DELETE /modules/:id */
export const deleteTopLevelCourseModule = (moduleId: number) =>
http.delete(`/modules/${moduleId}`)
/** Learn English top-level module lessons — GET /modules/:moduleId/lessons */
export const getModuleLessons = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetTopLevelModuleLessonsResponse>(`/modules/${moduleId}/lessons`, {
params,
})
/** Learn English top-level module lesson — POST /modules/:moduleId/lessons */
export const createModuleLesson = (
moduleId: number,
data: CreateTopLevelModuleLessonRequest,
) =>
http.post<CreateTopLevelModuleLessonResponse>(`/modules/${moduleId}/lessons`, data)
/** Learn English top-level module lesson — PUT /lessons/:id */
export const updateTopLevelModuleLesson = (
lessonId: number,
data: UpdateTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`)
/** GET /courses/:courseId/practices — practices linked to a top-level course (at most one in normal use). */
export const getPracticesByParentCourse = (
courseId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/courses/${courseId}/practices`, { params })
/** GET /modules/:moduleId/practices */
export const getPracticesByParentModule = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/modules/${moduleId}/practices`, { params })
/** GET /lessons/:lessonId/practices */
export const getPracticesByParentLesson = (
lessonId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/lessons/${lessonId}/practices`, { params })
/** POST /practices — create a practice (story + question set) for course / module / lesson. */
export const createParentLinkedPractice = (data: CreateParentLinkedPracticeRequest) =>
http.post<CreateParentLinkedPracticeResponse>("/practices", data)
/** PUT /practices/:id */
export const updateParentLinkedPractice = (
practiceId: number,
data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** DELETE /practices/:id */
export const deleteParentLinkedPractice = (practiceId: number) =>
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
`/practices/${practiceId}`,
)
export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) =>
http.put(`/programs/${programId}`, data)
export const deleteLearningProgram = (programId: number) => http.delete(`/programs/${programId}`)
// ============================================
// Legacy APIs (deprecated - using SubCourse hierarchy now)
// Keeping for backward compatibility
@ -730,74 +383,6 @@ 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)

View File

@ -25,16 +25,6 @@ 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
@ -44,36 +34,6 @@ export interface UploadMediaFromUrlPayload extends UploadMediaOptions {
sourceUrl: string
}
const GOOGLE_DRIVE_HOSTS = new Set([
"drive.google.com",
"www.drive.google.com",
])
const getGoogleDriveFileId = (rawUrl: string): string | null => {
try {
const url = new URL(rawUrl.trim())
if (!GOOGLE_DRIVE_HOSTS.has(url.hostname.toLowerCase())) return null
const fromQuery = url.searchParams.get("id")?.trim()
if (fromQuery) return fromQuery
const fileMatch = url.pathname.match(/\/file\/d\/([^/]+)/i)
return fileMatch?.[1]?.trim() || null
} catch {
return null
}
}
const normalizeSourceUrlForUpload = (
mediaType: UploadMediaType,
sourceUrl: string,
): string => {
const trimmed = sourceUrl.trim()
if (mediaType !== "image") return trimmed
const fileId = getGoogleDriveFileId(trimmed)
if (!fileId) return trimmed
// Use Drive thumbnail endpoint so backend receives actual image bytes, not HTML viewer.
return `https://drive.google.com/thumbnail?id=${encodeURIComponent(fileId)}&sz=w2048`
}
export const uploadMediaFile = (
mediaType: UploadMediaType,
file: File,
@ -97,7 +57,7 @@ export const uploadMediaFromUrl = (
) =>
http.post<UploadMediaResponse>("/files/upload", {
media_type: mediaType,
source_url: normalizeSourceUrlForUpload(mediaType, payload.sourceUrl),
source_url: payload.sourceUrl,
...(mediaType === "video" && payload.title ? { title: payload.title } : {}),
...(mediaType === "video" && payload.description ? { description: payload.description } : {}),
})
@ -126,8 +86,3 @@ export const resolveFileUrl = (key: string) =>
params: { key },
})
export const refreshFileUrl = (reference: string) =>
http.post<RefreshFileUrlResponse>("/files/refresh-url", {
reference,
})

View File

@ -12,7 +12,6 @@ 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) => {
@ -33,68 +32,23 @@ const clearAuthAndRedirect = () => {
window.location.href = "/login";
};
const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
try {
const payloadPart = token.split(".")[1];
if (!payloadPart) return null;
const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const json = atob(padded);
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
};
const isAccessTokenExpiringSoon = (token: string) => {
const payload = decodeJwtPayload(token);
const exp = Number(payload?.exp);
if (!Number.isFinite(exp)) return true;
const nowSeconds = Math.floor(Date.now() / 1000);
return exp - nowSeconds <= TOKEN_REFRESH_BUFFER_SECONDS;
};
const isAuthEndpointRequest = (url?: string) => {
if (!url) return false;
return (
url.includes("/team/login") ||
url.includes("/team/google-login") ||
url.includes("/team/refresh")
);
};
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
const safeOrigin = (url?: string): string | null => {
if (!url) return null;
try {
return new URL(url).origin;
} catch {
return null;
}
};
const API_BASE_ORIGIN = safeOrigin(import.meta.env.VITE_API_BASE_URL);
const shouldAttachApiAuth = (url?: string): boolean => {
if (!url) return true;
if (!ABSOLUTE_URL_REGEX.test(url)) return true;
const requestOrigin = safeOrigin(url);
if (!requestOrigin || !API_BASE_ORIGIN) return false;
return requestOrigin === API_BASE_ORIGIN;
};
const refreshAccessToken = async (): Promise<string> => {
const accessToken = localStorage.getItem("access_token");
const refreshToken = localStorage.getItem("refresh_token");
const role = localStorage.getItem("role");
const memberId = localStorage.getItem("member_id");
if (!refreshToken) {
if (!refreshToken || !memberId) {
throw new Error("No refresh token available");
}
const response = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
{
access_token: accessToken,
refresh_token: refreshToken,
role: role || "admin",
member_id: Number(memberId),
}
);
@ -111,47 +65,9 @@ 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(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();
}
http.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
@ -164,25 +80,37 @@ http.interceptors.response.use(
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (
error.response?.status === 401 &&
!originalRequest._retry &&
shouldAttachApiAuth(originalRequest.url) &&
!isAuthEndpointRequest(originalRequest.url)
) {
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));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await getValidAccessToken(true);
const newToken = await refreshAccessToken();
processQueue(null, newToken);
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 && shouldAttachApiAuth(originalRequest.url)) {
if (!error.response) {
clearAuthAndRedirect();
return Promise.reject(error);
}

View File

@ -5,7 +5,6 @@ import type {
GetRolesParams,
CreateRoleRequest,
CreateRoleResponse,
DeleteRoleResponse,
SetRolePermissionsRequest,
GetPermissionsResponse,
} from "../types/rbac.types"
@ -27,6 +26,3 @@ 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}`)

View File

@ -3,6 +3,7 @@ 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";
@ -46,7 +47,7 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
import { QuestionsPage } from "../pages/content-management/QuestionsPage";
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage";
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";
@ -90,10 +91,10 @@ export function AppRoutes() {
</Route>
<Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<Navigate to="practices" replace />} />
<Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguageHierarchyPage />} />
<Route path="human-language" element={<HumanLanguagePage />} />
<Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
element={<AddNewPracticePage />}

View File

@ -14,8 +14,6 @@ import { Select } from "../ui/select"
import { Button } from "../ui/button"
import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils"
import { ResolvedAudio } from "../media/ResolvedAudio"
import { ResolvedImage } from "../media/ResolvedImage"
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
@ -817,7 +815,7 @@ export function PracticeQuestionEditorFields({
disabled={controlsDisabled}
/>
{voicePreviewUrl ? (
<ResolvedAudio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
<audio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
) : null}
</div>
</div>
@ -864,7 +862,7 @@ export function PracticeQuestionEditorFields({
disabled={controlsDisabled}
/>
{samplePreviewUrl ? (
<ResolvedAudio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
<audio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
) : null}
</div>
</div>
@ -900,7 +898,7 @@ export function PracticeQuestionEditorFields({
disabled={controlsDisabled}
/>
{imagePreviewUrl ? (
<ResolvedImage
<img
src={imagePreviewUrl}
alt=""
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"

View File

@ -1,33 +0,0 @@
import { useEffect, useState, type AudioHTMLAttributes } from "react"
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
type ResolvedAudioProps = AudioHTMLAttributes<HTMLAudioElement> & {
src?: string | null
}
export function ResolvedAudio({ src, ...audioProps }: ResolvedAudioProps) {
const [resolvedSrc, setResolvedSrc] = useState("")
useEffect(() => {
let cancelled = false
;(async () => {
const raw = (src ?? "").trim()
if (!raw) {
setResolvedSrc("")
return
}
try {
const next = await resolveDisplayMediaUrl(raw)
if (!cancelled) setResolvedSrc(next || raw)
} catch {
if (!cancelled) setResolvedSrc(raw)
}
})()
return () => {
cancelled = true
}
}, [src])
if (!resolvedSrc) return null
return <audio {...audioProps} src={resolvedSrc} />
}

View File

@ -1,35 +0,0 @@
import { useEffect, useState, type ImgHTMLAttributes } from "react"
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
type ResolvedImageProps = ImgHTMLAttributes<HTMLImageElement> & {
src?: string | null
fallbackSrc?: string
}
export function ResolvedImage({ src, fallbackSrc, ...imgProps }: ResolvedImageProps) {
const [resolvedSrc, setResolvedSrc] = useState("")
useEffect(() => {
let cancelled = false
;(async () => {
const raw = (src ?? "").trim()
if (!raw) {
setResolvedSrc("")
return
}
try {
const next = await resolveDisplayMediaUrl(raw)
if (!cancelled) setResolvedSrc(next || raw)
} catch {
if (!cancelled) setResolvedSrc(raw)
}
})()
return () => {
cancelled = true
}
}, [src])
const finalSrc = resolvedSrc || fallbackSrc || ""
if (!finalSrc) return null
return <img {...imgProps} src={finalSrc} />
}

View File

@ -12,7 +12,6 @@ import {
UserCircle2,
Users,
Users2,
Settings,
X,
} from "lucide-react";
import { type ComponentType, useEffect, useState } from "react";
@ -40,7 +39,6 @@ const navItems: NavItem[] = [
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
{ label: "Team Management", to: "/team", icon: Users2 },
{ label: "Profile", to: "/profile", icon: UserCircle2 },
{ label: "Settings", to: "/settings", icon: Settings },
];
type SidebarProps = {

View File

@ -45,7 +45,7 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
}
return (
<header className="sticky top-0 z-40 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
<header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
{/* Sidebar toggle */}
<button
type="button"

View File

@ -1,18 +1,16 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"
import { Navigate, Outlet, useLocation } from "react-router-dom"
import { useState, useCallback } from "react"
import { Navigate, Outlet } 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)
@ -22,43 +20,6 @@ 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
@ -73,7 +34,7 @@ export function AppLayout() {
}`}
>
<Topbar onSidebarToggle={handleSidebarToggle} />
<main ref={mainRef} className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
<main className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
<Outlet />
</main>
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">

View File

@ -1,40 +0,0 @@
import { refreshFileUrl, resolveFileUrl } from "../api/files.api"
const HTTP_REGEX = /^https?:\/\//i
export function isHttpUrl(value: string): boolean {
return HTTP_REGEX.test(value.trim())
}
export function isSignedMinioUrl(value: string): boolean {
const trimmed = value.trim()
if (!isHttpUrl(trimmed)) return false
try {
const url = new URL(trimmed)
return (
url.host === "s3.yimaruacademy.com" &&
(url.searchParams.has("X-Amz-Signature") || url.searchParams.has("X-Amz-Expires"))
)
} catch {
return false
}
}
export async function resolveDisplayMediaUrl(value: string): Promise<string> {
const trimmed = value.trim()
if (!trimmed) return ""
if (isHttpUrl(trimmed)) {
if (!isSignedMinioUrl(trimmed)) return trimmed
try {
const refreshed = await refreshFileUrl(trimmed)
const refreshedUrl = refreshed.data?.data?.url?.trim()
return refreshedUrl || trimmed
} catch {
return trimmed
}
}
const resolved = await resolveFileUrl(trimmed)
return resolved.data?.data?.url?.trim() || ""
}

View File

@ -1,4 +1,4 @@
import { resolveDisplayMediaUrl } from "./mediaUrl"
import { resolveFileUrl } from "../api/files.api"
export function normalizeObjectKey(value: string): string {
const trimmed = value.trim()
@ -12,7 +12,10 @@ export function normalizeObjectKey(value: string): string {
}
export async function resolveMediaPreviewUrl(value: string): Promise<string> {
const normalized = normalizeObjectKey(value)
if (!normalized) return ""
return resolveDisplayMediaUrl(normalized)
if (!value.trim()) return ""
if (value.startsWith("http://") || value.startsWith("https://")) return value
const key = normalizeObjectKey(value)
if (!key) return ""
const res = await resolveFileUrl(key)
return res.data?.data?.url ?? ""
}

View File

@ -1,13 +0,0 @@
const ADMIN_OR_SUPER: ReadonlySet<string> = new Set([
"admin",
"super_admin",
]);
/**
* True when the stored session role is admin or super_admin (login stores `role` in localStorage).
*/
export function isAdminOrSuperAdminRole(): boolean {
const raw = localStorage.getItem("role");
if (!raw) return false;
return ADMIN_OR_SUPER.has(raw.trim().toLowerCase());
}

View File

@ -1,132 +0,0 @@
/**
* Resolves a user-facing video URL into something we can preview (iframe or <video>).
*/
export function toVimeoEmbedUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl.trim());
const host = parsed.hostname.toLowerCase();
if (!host.includes("vimeo.com")) return null;
if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) {
return parsed.toString();
}
const segments = parsed.pathname.split("/").filter(Boolean);
const videoId = segments.find((segment) => /^\d+$/.test(segment));
if (!videoId) return null;
// Vimeo private/unlisted links often come as /<videoId>/<hash> instead of ?h=<hash>.
const hashFromPath = (() => {
const videoIdx = segments.findIndex((segment) => segment === videoId);
if (videoIdx < 0) return null;
const maybeHash = segments[videoIdx + 1];
if (!maybeHash) return null;
return /^[a-zA-Z0-9]+$/.test(maybeHash) ? maybeHash : null;
})();
const hash = parsed.searchParams.get("h") || hashFromPath;
return hash
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
: `https://player.vimeo.com/video/${videoId}`;
} catch {
return null;
}
}
export function toYoutubeEmbedUrl(rawUrl: string): string | null {
try {
const u = new URL(rawUrl.trim());
const host = u.hostname.replace(/^www\./, "").toLowerCase();
if (host === "youtu.be") {
const id = u.pathname.split("/").filter(Boolean)[0];
if (id) return `https://www.youtube.com/embed/${id}`;
}
if (host === "youtube.com" || host === "m.youtube.com") {
const v = u.searchParams.get("v");
if (v) return `https://www.youtube.com/embed/${v}`;
let m = u.pathname.match(/\/embed\/([^/]+)/);
if (m) return `https://www.youtube.com/embed/${m[1]}`;
m = u.pathname.match(/\/shorts\/([^/]+)/);
if (m) return `https://www.youtube.com/embed/${m[1]}`;
}
} catch {
return null;
}
return null;
}
export function isDirectVideoFileUrl(url: string): boolean {
const clean = url.split("?")[0].toLowerCase();
return /^https?:\/\//.test(url.trim()) && /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
}
export type VideoPreviewKind =
| { kind: "iframe"; src: string; label: "Vimeo" | "YouTube" }
| { kind: "video"; src: string }
| { kind: "none" };
export function getVideoPreview(url: string): VideoPreviewKind {
const t = url.trim();
if (!t) return { kind: "none" };
const vimeo = toVimeoEmbedUrl(t);
if (vimeo) return { kind: "iframe", src: vimeo, label: "Vimeo" };
const yt = toYoutubeEmbedUrl(t);
if (yt) return { kind: "iframe", src: yt, label: "YouTube" };
if (isDirectVideoFileUrl(t)) return { kind: "video", src: t };
return { kind: "none" };
}
/**
* First N seconds only embed short preview in admin cards / review, not the full file.
* @see https://developers.google.com/youtube/player_parameters (end, start)
*/
export const DEFAULT_PREVIEW_MAX_SECONDS = 60;
export function formatPreviewLength(totalSeconds: number): string {
if (totalSeconds < 60) return `${totalSeconds} seconds`;
if (totalSeconds % 60 === 0) {
const m = totalSeconds / 60;
return m === 1 ? "1 minute" : `${m} minutes`;
}
return `${totalSeconds} seconds`;
}
/**
* YouTube: `end` = stop after this many seconds from the start of the video.
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
*/
export function applyShortPreviewToEmbedUrl(
embedUrl: string,
label: "Vimeo" | "YouTube",
maxSeconds: number = DEFAULT_PREVIEW_MAX_SECONDS,
): string {
try {
if (label === "YouTube") {
const u = new URL(embedUrl);
u.searchParams.set("start", "0");
u.searchParams.set("end", String(maxSeconds));
u.searchParams.set("rel", u.searchParams.get("rel") ?? "0");
return u.toString();
}
if (label === "Vimeo") {
const u = new URL(embedUrl);
u.searchParams.set("start", "0");
u.searchParams.set("end", String(maxSeconds));
u.hash = `t=0,${maxSeconds}`;
return u.toString();
}
} catch {
// fall through
}
return embedUrl;
}
/** Google Drive "view" links are not direct image URLs; use the thumbnail API for preview. */
export function resolveThumbnailForPreview(
url: string | null | undefined,
): string | null {
if (!url?.trim()) return null;
const t = url.trim();
const m = t.match(/\/file\/d\/([a-zA-Z0-9_-]+)/);
if (m) {
return `https://drive.google.com/thumbnail?id=${m[1]}&sz=w800`;
}
return t;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
Bell,
Eye,
@ -13,43 +13,21 @@ import {
Shield,
Sun,
User,
CreditCard,
AlertTriangle,
X,
} from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types";
import { toast } from "sonner";
type SettingsTab =
| "subscription"
| "profile"
| "security"
| "notifications"
| "appearance";
type SettingsTab = "profile" | "security" | "notifications" | "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
{ id: "subscription", label: "Subscription", icon: CreditCard },
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "notifications", label: "Notifications", icon: Bell },
@ -70,14 +48,14 @@ function Toggle({
aria-checked={enabled}
onClick={onToggle}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
enabled ? "bg-brand-500" : "bg-grayScale-200",
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2",
enabled ? "bg-brand-500" : "bg-grayScale-200"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
@ -90,20 +68,20 @@ function SettingRow({
description,
children,
}: {
icon: any;
icon: typeof User;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-800">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
<p className="text-sm font-medium text-grayScale-600">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-400">{description}</p>
</div>
</div>
<div className="shrink-0">{children}</div>
@ -111,143 +89,34 @@ function SettingRow({
);
}
// --- Subscription Tab ---
function SubscriptionTab() {
const [subs, setSubs] = useState([
{
id: "auto_renew",
name: "Auto-renewal",
desc: "Automatically renew your subscription when it expires",
enabled: true,
},
{
id: "marketing_emails",
name: "Marketing Emails",
desc: "Receive updates about new features and promotions",
enabled: true,
},
{
id: "priority_support",
name: "Priority Support",
desc: "Access 24/7 priority customer support",
enabled: true,
},
]);
const [pendingToggle, setPendingToggle] = useState<string | null>(null);
const [showWarning, setShowWarning] = useState(false);
const handleToggle = (id: string) => {
const item = subs.find((s) => s.id === id);
if (item?.enabled) {
setPendingToggle(id);
setShowWarning(true);
} else {
setSubs((prev) =>
prev.map((s) => (s.id === id ? { ...s, enabled: true } : s)),
);
}
};
const confirmToggleOff = () => {
if (pendingToggle) {
setSubs((prev) =>
prev.map((s) =>
s.id === pendingToggle ? { ...s, enabled: false } : s,
),
);
setShowWarning(false);
setPendingToggle(null);
}
};
function LoadingSkeleton() {
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Subscription Features
</CardTitle>
<p className="text-[11px] text-grayScale-500">
Customize your subscription experience and management preferences
</p>
</CardHeader>
<CardContent className="space-y-0 p-0">
{subs.map((sub, idx) => (
<React.Fragment key={sub.id}>
<div
className={cn(
"px-2",
idx < subs.length - 1 && "border-b border-grayScale-50",
)}
>
<SettingRow
icon={CreditCard}
title={sub.name}
description={sub.desc}
>
<Toggle
enabled={sub.enabled}
onToggle={() => handleToggle(sub.id)}
/>
</SettingRow>
</div>
</React.Fragment>
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
<div className="animate-pulse space-y-6">
<div className="h-7 w-32 rounded-lg bg-grayScale-100" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-28 rounded-lg bg-grayScale-100" />
))}
</CardContent>
</Card>
<Dialog open={showWarning} onOpenChange={setShowWarning}>
<DialogContent className="max-w-md p-0 overflow-hidden border border-grayScale-100 rounded-[12px] shadow-2xl">
<div className="relative p-8">
<div className="flex items-start gap-5 mb-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-500 border border-red-100">
<AlertTriangle className="h-7 w-7" />
</div>
<div className="rounded-2xl border border-grayScale-100 p-6">
<div className="space-y-6">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 w-32 rounded bg-grayScale-100" />
<div className="h-3 w-48 rounded bg-grayScale-100" />
</div>
<div className="h-10 w-48 rounded-lg bg-grayScale-100" />
</div>
<div className="pt-1">
<h3 className="text-xl font-bold text-grayScale-900 tracking-tight">
Are you absolutely sure?
</h3>
<p className="text-sm text-grayScale-500 mt-1">
Disabling this feature might limit your experience.
</p>
</div>
</div>
<div className="bg-grayScale-50/80 border border-grayScale-100 p-5 rounded-[8px] mb-8">
<p className="text-sm text-grayScale-600 leading-relaxed font-medium">
By turning this off, you will no longer receive the benefits
associated with this feature. Some changes might take up to 24
hours to reflect.
</p>
</div>
<div className="flex flex-col gap-3">
<Button
variant="destructive"
onClick={confirmToggleOff}
className="w-full rounded-[8px] py-6 text-sm font-bold bg-red-500 hover:bg-red-600 text-white border-none shadow-sm transition-all active:scale-[0.98]"
>
Yes, Disable Feature
</Button>
<Button
variant="outline"
onClick={() => setShowWarning(false)}
className="w-full rounded-[8px] py-6 text-sm font-bold border-grayScale-200 text-grayScale-600 hover:bg-grayScale-50 transition-all active:scale-[0.98]"
>
Cancel
</Button>
</div>
))}
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
);
}
// --- Other Tabs (Existing, but with sidebar layout updates) ---
function ProfileTab({ profile }: { profile: UserProfileData }) {
const [firstName, setFirstName] = useState(profile.first_name);
const [lastName, setLastName] = useState(profile.last_name);
@ -273,88 +142,79 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<div className="h-1 w-full bg-brand-500" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Personal Information
</CardTitle>
<div className="space-y-6">
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<User className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Personal Information
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
First Name
</label>
<Input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="rounded-[6px]"
/>
<label className="text-xs font-medium text-grayScale-500">First Name</label>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Last Name
</label>
<Input
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="rounded-[6px]"
/>
<label className="text-xs font-medium text-grayScale-500">Last Name</label>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Nickname
</label>
<Input
value={nickName}
onChange={(e) => setNickName(e.target.value)}
className="rounded-[6px]"
/>
<label className="text-xs font-medium text-grayScale-500">Nickname</label>
<Input value={nickName} onChange={(e) => setNickName(e.target.value)} />
</div>
</CardContent>
</Card>
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Preferences
</CardTitle>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
<Languages className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Preferences
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Preferred Language
</label>
<Select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="rounded-[6px]"
>
<label className="text-xs font-medium text-grayScale-500">Preferred Language</label>
<Select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="am">Amharic</option>
<option value="or">Afan Oromo</option>
<option value="ti">Tigrinya</option>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Timezone</label>
<Select defaultValue="eat">
<option value="eat">East Africa Time (UTC+3)</option>
<option value="utc">UTC</option>
<option value="est">Eastern Time (UTC-5)</option>
<option value="pst">Pacific Time (UTC-8)</option>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving}
className="min-w-[140px] rounded-[6px] font-bold"
>
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Save className="h-4 w-4 mr-2" />
<Save className="h-4 w-4" />
)}
{saving ? "Saving…" : "Save Changes"}
</Button>
@ -380,102 +240,96 @@ function SecurityTab() {
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<div className="h-1 w-full bg-brand-600" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Change Password
</CardTitle>
<div className="space-y-6">
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
<KeyRound className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Change Password
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Current Password
</label>
<label className="text-xs font-medium text-grayScale-500">Current Password</label>
<div className="relative">
<Input
type={showCurrent ? "text" : "password"}
placeholder="Enter current password"
className="rounded-[6px]"
/>
<Input type={showCurrent ? "text" : "password"} placeholder="Enter current password" />
<button
type="button"
onClick={() => setShowCurrent(!showCurrent)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showCurrent ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
New Password
</label>
<label className="text-xs font-medium text-grayScale-500">New Password</label>
<div className="relative">
<Input
type={showNew ? "text" : "password"}
placeholder="Enter new password"
className="rounded-[6px]"
/>
<Input type={showNew ? "text" : "password"} placeholder="Enter new password" />
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showNew ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Confirm New Password
</label>
<label className="text-xs font-medium text-grayScale-500">Confirm New Password</label>
<div className="relative">
<Input
type={showConfirm ? "text" : "password"}
placeholder="Confirm new password"
className="rounded-[6px]"
/>
<Input type={showConfirm ? "text" : "password"} placeholder="Confirm new password" />
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showConfirm ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
onClick={handleChangePassword}
disabled={saving}
className="min-w-[160px] rounded-[6px] font-bold"
>
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Lock className="h-4 w-4 mr-2" />
<Lock className="h-4 w-4" />
)}
{saving ? "Updating…" : "Update Password"}
</Button>
</div>
</CardContent>
</Card>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-400 text-white shadow-sm">
<Shield className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Two-Factor Authentication
</CardTitle>
</div>
</CardHeader>
<CardContent className="pb-6">
<SettingRow
icon={Shield}
title="Enable 2FA"
description="Add an extra layer of security to your account"
>
<Toggle enabled={false} onToggle={() => toast.info("2FA coming soon")} />
</SettingRow>
</CardContent>
</Card>
</div>
);
}
@ -484,14 +338,20 @@ function NotificationsTab() {
const [emailNotifs, setEmailNotifs] = useState(true);
const [pushNotifs, setPushNotifs] = useState(true);
const [loginAlerts, setLoginAlerts] = useState(true);
const [weeklyDigest, setWeeklyDigest] = useState(false);
return (
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="h-1 w-full bg-brand-500" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Notification Preferences
</CardTitle>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<Bell className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Notification Preferences
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-1 pb-6">
<SettingRow
@ -499,32 +359,31 @@ function NotificationsTab() {
title="Email Notifications"
description="Receive important updates via email"
>
<Toggle
enabled={emailNotifs}
onToggle={() => setEmailNotifs(!emailNotifs)}
/>
<Toggle enabled={emailNotifs} onToggle={() => setEmailNotifs(!emailNotifs)} />
</SettingRow>
<Separator className="bg-grayScale-50" />
<Separator />
<SettingRow
icon={Bell}
title="Push Notifications"
description="Get notified in the browser"
>
<Toggle
enabled={pushNotifs}
onToggle={() => setPushNotifs(!pushNotifs)}
/>
<Toggle enabled={pushNotifs} onToggle={() => setPushNotifs(!pushNotifs)} />
</SettingRow>
<Separator className="bg-grayScale-50" />
<Separator />
<SettingRow
icon={Shield}
title="Login Alerts"
description="Get notified when someone logs into your account"
>
<Toggle
enabled={loginAlerts}
onToggle={() => setLoginAlerts(!loginAlerts)}
/>
<Toggle enabled={loginAlerts} onToggle={() => setLoginAlerts(!loginAlerts)} />
</SettingRow>
<Separator />
<SettingRow
icon={Globe}
title="Weekly Digest"
description="Receive a weekly summary of activity"
>
<Toggle enabled={weeklyDigest} onToggle={() => setWeeklyDigest(!weeklyDigest)} />
</SettingRow>
</CardContent>
</Card>
@ -535,12 +394,17 @@ function AppearanceTab() {
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
return (
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Theme
</CardTitle>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-sm">
<Palette className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Theme
</CardTitle>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="grid gap-3 sm:grid-cols-3">
@ -556,18 +420,16 @@ function AppearanceTab() {
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
"flex flex-col items-center gap-2.5 rounded-xl border-2 px-4 py-5 transition-all",
theme === id
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
? "border-brand-500 bg-brand-100/30 text-brand-600 shadow-sm"
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-100/40"
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-[6px]",
theme === id
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-400",
"flex h-10 w-10 items-center justify-center rounded-lg",
theme === id ? "bg-brand-500 text-white" : "bg-grayScale-100 text-grayScale-400"
)}
>
<Icon className="h-5 w-5" />
@ -582,7 +444,7 @@ function AppearanceTab() {
}
export function SettingsPage() {
const [activeTab, setActiveTab] = useState<SettingsTab>("subscription");
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -602,27 +464,21 @@ export function SettingsPage() {
fetchProfile();
}, []);
if (loading) {
return (
<div className="flex h-[400px] items-center justify-center">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
</div>
);
}
if (loading) return <LoadingSkeleton />;
if (error || !profile) {
return (
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<Card className="border-dashed border-grayScale-200 rounded-[6px]">
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
<User className="h-10 w-10 text-grayScale-300" />
</div>
<div className="text-center">
<p className="text-lg font-bold tracking-tight text-grayScale-900">
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
{error || "Settings not available"}
</p>
<p className="mt-1 text-sm text-grayScale-500">
<p className="mt-1 text-sm text-grayScale-400">
Please check your connection and try again.
</p>
</div>
@ -633,23 +489,40 @@ export function SettingsPage() {
}
return (
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6">
<div className="mb-10 ">
<h1 className="text-2xl font-black tracking-tight text-grayScale-700">
Settings
</h1>
<p className="mt-2 text-sm text-grayScale-500 ">
Manage your account preferences, subscriptions, and system
configurations with ease
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Settings</h1>
<p className="mt-1 text-sm text-grayScale-400">
Manage your account preferences and configuration
</p>
</div>
<div className="flex flex-col gap-8">
{/* Content Area */}
<main className="min-h-[400px]">
{activeTab === "subscription" && <SubscriptionTab />}
</main>
{/* Tab navigation */}
<div className="flex gap-1 rounded-xl border border-grayScale-100 bg-grayScale-100/50 p-1">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setActiveTab(id)}
className={cn(
"flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all",
activeTab === id
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-400 hover:text-grayScale-600"
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
{/* Tab content */}
{activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab />}
{activeTab === "notifications" && <NotificationsTab />}
{activeTab === "appearance" && <AppearanceTab />}
</div>
);
}

View File

@ -67,6 +67,9 @@ 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("");
@ -159,10 +162,6 @@ export function LoginPage() {
}
}, [googleReady, handleGoogleCallback]);
if (token) {
return <Navigate to="/dashboard" replace />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

View File

@ -1,650 +0,0 @@
import { useMemo, useState, type ChangeEvent } from "react"
import { ArrowLeft, ArrowRight, Check, GripVertical, Plus, Rocket, Trash2, Upload } from "lucide-react"
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
import { toast } from "sonner"
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api"
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
type ResultStatus = "success" | "error"
interface MCQOption {
text: string
isCorrect: boolean
}
interface Question {
id: string
questionText: string
questionType: QuestionType
difficultyLevel: DifficultyLevel
points: number
tips: string
explanation: string
options: MCQOption[]
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
shortAnswers: string[]
imageUrl: string
}
const STEPS = [
{ number: 1, label: "Context" },
{ number: 2, label: "Questions" },
{ number: 3, label: "Review" },
]
function createEmptyQuestion(id: string): Question {
return {
id,
questionText: "",
questionType: "MCQ",
difficultyLevel: "EASY",
points: 1,
tips: "",
explanation: "",
options: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
shortAnswers: [],
imageUrl: "",
}
}
function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null {
if (!data) return null
const pageUrl = data.url?.trim()
const embedUrl = data.embed_url?.trim()
if (embedUrl) {
const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined
return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
}
return pageUrl || null
}
function toVimeoEmbedUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl.trim())
const host = parsed.hostname.toLowerCase()
if (!host.includes("vimeo.com")) return null
if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) return parsed.toString()
const segments = parsed.pathname.split("/").filter(Boolean)
const videoId = segments.find((segment) => /^\d+$/.test(segment))
if (!videoId) return null
const hash = parsed.searchParams.get("h")
return hash
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
: `https://player.vimeo.com/video/${videoId}`
} catch {
return null
}
}
function isDirectVideoFile(url: string): boolean {
const clean = url.split("?")[0].toLowerCase()
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean)
}
function questionTypeLabel(type: QuestionType): string {
if (type === "TRUE_FALSE") return "True/False"
if (type === "SHORT") return "Short Answer"
if (type === "AUDIO") return "Audio"
return "Multiple Choice"
}
export function AddNewLessonPage() {
const { categoryId, courseId, subModuleId } = useParams()
const navigate = useNavigate()
const location = useLocation()
const backTo = useMemo(() => {
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
}
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
}, [categoryId, courseId, subModuleId, location.pathname])
const [currentStep, setCurrentStep] = useState<Step>(1)
const [saving, setSaving] = useState(false)
const [resultStatus, setResultStatus] = useState<ResultStatus | null>(null)
const [resultMessage, setResultMessage] = useState("")
const [lastSavedStatus, setLastSavedStatus] = useState<"DRAFT" | "PUBLISHED" | null>(null)
const [lessonTitle, setLessonTitle] = useState("")
const [lessonDescription, setLessonDescription] = useState("")
const [introVideoUrl, setIntroVideoUrl] = useState("")
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
const [questions, setQuestions] = useState<Question[]>([createEmptyQuestion("1")])
const handleNext = () => setCurrentStep((s) => (s < 3 ? ((s + 1) as Step) : s))
const handleBack = () => setCurrentStep((s) => (s > 1 ? ((s - 1) as Step) : s))
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file) return
setUploadingIntroVideo(true)
try {
const uploadRes = await uploadVideoFile(file, {
title: lessonTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Lesson intro",
description: lessonDescription.trim() || undefined,
})
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
if (!finalUrl) throw new Error("Missing uploaded video url")
setIntroVideoUrl(finalUrl)
toast.success("Intro video uploaded")
} catch (error) {
console.error("Failed to upload lesson intro video:", error)
toast.error("Failed to upload intro video")
} finally {
setUploadingIntroVideo(false)
}
}
const handleIntroVideoUrlBlur = async () => {
const source = introVideoUrl.trim()
if (!source || !/^https?:\/\//i.test(source)) return
const vimeoEmbed = toVimeoEmbedUrl(source)
if (vimeoEmbed) {
setIntroVideoUrl(vimeoEmbed)
return
}
if (isDirectVideoFile(source)) {
setIntroVideoUrl(source)
return
}
// For non-direct URLs, automatically try server-side import via /files/upload.
setUploadingIntroVideo(true)
try {
const uploadRes = await uploadVideoFile(source, {
title: lessonTitle.trim() || "Lesson intro",
description: lessonDescription.trim() || undefined,
})
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
if (!finalUrl) throw new Error("Missing uploaded video url")
setIntroVideoUrl(finalUrl)
toast.success("Intro video URL imported")
} catch (error) {
console.error("Failed to import intro video URL:", error)
toast.error("Failed to import intro video URL")
} finally {
setUploadingIntroVideo(false)
}
}
const introVideoPreview = useMemo(() => {
const raw = introVideoUrl.trim()
if (!raw) return null
const vimeoEmbedUrl = toVimeoEmbedUrl(raw)
if (vimeoEmbedUrl) return { kind: "vimeo" as const, url: vimeoEmbedUrl }
if (isDirectVideoFile(raw)) return { kind: "video" as const, url: raw }
return null
}, [introVideoUrl])
const reviewQuestions = useMemo(() => questions, [questions])
const addQuestion = () => setQuestions((prev) => [...prev, createEmptyQuestion(String(Date.now()))])
const removeQuestion = (id: string) => setQuestions((prev) => (prev.length > 1 ? prev.filter((q) => q.id !== id) : prev))
const updateQuestion = (id: string, updates: Partial<Question>) =>
setQuestions((prev) => prev.map((q) => (q.id === id ? { ...q, ...updates } : q)))
const saveLesson = async (status: "DRAFT" | "PUBLISHED") => {
if (!subModuleId) {
toast.error("Missing sub-module id")
return
}
setSaving(true)
try {
const lessonRes = await createLesson({
sub_module_id: Number(subModuleId),
title: lessonTitle.trim() || "Untitled Lesson",
description: lessonDescription.trim() || undefined,
intro_video_url: introVideoUrl.trim() || undefined,
status,
})
const questionSetId = lessonRes.data?.data?.id
if (questionSetId) {
for (let i = 0; i < questions.length; i++) {
const q = questions[i]
if (!q.questionText.trim()) continue
const options: QuestionOption[] =
q.questionType === "MCQ"
? q.options.map((opt, idx) => ({
option_order: idx + 1,
option_text: opt.text,
is_correct: opt.isCorrect,
}))
: []
const qRes = await createQuestion({
question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status: "PUBLISHED",
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
})
const questionId = qRes.data?.data?.id
if (questionId) {
await addQuestionToSet(questionSetId, { question_id: questionId, display_order: i + 1 })
}
}
}
setResultStatus("success")
setResultMessage(status === "PUBLISHED" ? "Lesson published successfully." : "Lesson saved as draft.")
setLastSavedStatus(status)
setCurrentStep(4)
} catch (error) {
console.error("Failed to save lesson:", error)
setResultStatus("error")
setResultMessage(error instanceof Error ? error.message : "Failed to save lesson")
setLastSavedStatus(null)
setCurrentStep(4)
} finally {
setSaving(false)
}
}
return (
<div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
<div className="space-y-5 sm:space-y-6">
{currentStep !== 4 ? (
<>
<Link
to={backTo}
className="group inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-grayScale-600 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
Back to Sub-course
</Link>
<div className="border-b border-grayScale-100 pb-6 sm:pb-8">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add New Lesson</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-grayScale-500 sm:text-[15px]">
Create a lesson backed by `question_sets` and attach it through `sub_module_lessons`.
</p>
</div>
<div className="flex items-center justify-center rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-3 py-4 sm:px-6 sm:py-5">
{STEPS.map((step, index) => (
<div key={step.number} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold shadow-sm transition-all duration-300 sm:h-10 sm:w-10 sm:text-sm ${
currentStep === step.number
? "bg-brand-500 text-white ring-4 ring-brand-100"
: currentStep > step.number
? "bg-brand-500 text-white"
: "border-2 border-grayScale-300 bg-white text-grayScale-400"
}`}
>
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
</div>
<span className="mt-2 text-xs font-semibold text-grayScale-500">{step.label}</span>
</div>
{index < STEPS.length - 1 ? (
<div className={`mx-4 h-0.5 w-20 ${currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"}`} />
) : null}
</div>
))}
</div>
</>
) : null}
{currentStep === 1 ? (
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Context</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
Define lesson metadata that will be stored in the linked question set.
</p>
</div>
<div className="p-5 sm:p-8 lg:p-10">
<div className="mt-5 grid gap-8 lg:grid-cols-12">
<div className="space-y-4 lg:col-span-7">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Lesson title</label>
<Input
value={lessonTitle}
onChange={(e) => setLessonTitle(e.target.value)}
placeholder="Enter lesson title"
className="h-11"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea
value={lessonDescription}
onChange={(e) => setLessonDescription(e.target.value)}
className="min-h-[96px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-grayScale-400 focus:outline-none focus:ring-2 focus:ring-grayScale-100"
placeholder="Enter lesson description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Intro video URL (optional)</label>
<Input
value={introVideoUrl}
onChange={(e) => setIntroVideoUrl(e.target.value)}
onBlur={() => void handleIntroVideoUrlBlur()}
placeholder="https://..."
type="url"
inputMode="url"
autoComplete="off"
className="font-mono text-[13px]"
/>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50">
{uploadingIntroVideo ? <SpinnerIcon className="h-4 w-4" alt="" /> : <Upload className="h-4 w-4" />}
{uploadingIntroVideo ? "Uploading..." : "Upload video from computer"}
<input
type="file"
accept="video/*"
className="hidden"
onChange={handleIntroVideoFileChange}
disabled={uploadingIntroVideo}
/>
</label>
{introVideoUrl.trim() ? (
<Button type="button" variant="ghost" size="sm" onClick={() => setIntroVideoUrl("")}>
Clear URL
</Button>
) : null}
</div>
{introVideoPreview ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-3">
<p className="mb-2 text-xs font-medium text-grayScale-500">Preview</p>
{introVideoPreview.kind === "vimeo" ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
<iframe
src={introVideoPreview.url}
title="Intro video preview"
className="aspect-video w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<video
controls
src={introVideoPreview.url}
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
/>
)}
</div>
) : null}
</div>
</div>
<aside className="space-y-4 lg:col-span-5">
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-5 shadow-sm">
<h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Lesson schema mapping</h3>
<div className="mt-3 space-y-2 text-sm text-grayScale-700">
<p>
<span className="font-medium">question_sets.title</span> Lesson title
</p>
<p>
<span className="font-medium">question_sets.description</span> Description
</p>
<p>
<span className="font-medium">question_sets.set_type</span> = QUIZ
</p>
<p>
<span className="font-medium">sub_module_lessons.intro_video_url</span> Intro URL
</p>
</div>
</div>
</aside>
</div>
</div>
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
<Button variant="ghost" onClick={() => navigate(backTo)} className="sm:w-auto">
Cancel
</Button>
<Button onClick={handleNext}>
Next: Questions
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</Card>
) : null}
{currentStep === 2 ? (
<div className="space-y-5">
{questions.map((question, index) => (
<Card key={question.id} className="border border-grayScale-200/90 border-l-4 border-l-grayScale-700 p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-grayScale-400" />
<span className="font-semibold">Question {index + 1}</span>
</div>
<button type="button" onClick={() => removeQuestion(question.id)} className="text-grayScale-400 hover:text-red-500">
<Trash2 className="h-4 w-4" />
</button>
</div>
<PracticeQuestionEditorFields
value={{
questionText: question.questionText,
questionType: question.questionType,
difficultyLevel: question.difficultyLevel,
points: question.points,
tips: question.tips,
explanation: question.explanation,
options: question.options,
voicePrompt: question.voicePrompt,
sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl,
}}
onChange={(next) =>
updateQuestion(question.id, {
questionText: next.questionText,
questionType: next.questionType as QuestionType,
difficultyLevel: next.difficultyLevel as DifficultyLevel,
points: next.points,
tips: next.tips,
explanation: next.explanation,
options: next.options,
voicePrompt: next.voicePrompt,
sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
audioCorrectAnswerText: next.audioCorrectAnswerText,
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
imageUrl: next.imageUrl,
})
}
mediaBusy={saving}
/>
</Card>
))}
<Button variant="outline" onClick={addQuestion} className="w-full border-dashed">
<Plus className="mr-2 h-4 w-4" />
Add another question
</Button>
<div className="flex items-center justify-between rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:px-6 sm:py-5">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<Button onClick={handleNext}>
Next: Review
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
) : null}
{currentStep === 3 ? (
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
<p className="mt-1.5 text-sm text-grayScale-500">Confirm lesson details and questions before saving or publishing.</p>
</div>
<div className="space-y-4 p-5 sm:p-8">
<div className="grid gap-4 lg:grid-cols-2">
<div className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white">
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3">
<h3 className="text-base font-semibold text-grayScale-900">Basic Information</h3>
<button
type="button"
className="text-sm font-medium text-brand-500 hover:text-brand-600"
onClick={() => setCurrentStep(1)}
>
Edit
</button>
</div>
<div className="divide-y divide-grayScale-100">
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Title</span>
<span className="font-medium text-grayScale-800">{lessonTitle || "Untitled Lesson"}</span>
</div>
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Description</span>
<span className="max-w-[55%] truncate text-right font-medium text-grayScale-800">
{lessonDescription || "—"}
</span>
</div>
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Intro video URL</span>
<span className="max-w-[55%] truncate text-right font-medium text-grayScale-800">{introVideoUrl || "—"}</span>
</div>
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-grayScale-500">Sub-module</span>
<span className="font-medium text-grayScale-800">{subModuleId ?? "—"}</span>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white">
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3">
<h3 className="text-base font-semibold text-grayScale-900">
Questions
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">
{reviewQuestions.length}
</span>
</h3>
<button
type="button"
className="text-sm font-medium text-brand-500 hover:text-brand-600"
onClick={() => setCurrentStep(2)}
>
Edit
</button>
</div>
<div className="space-y-3 p-3">
{reviewQuestions.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 p-4 text-sm text-grayScale-500">
No question content added yet.
</div>
) : (
reviewQuestions.map((question, idx) => (
<div key={question.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/35 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-100 px-1.5 text-[11px] font-semibold text-brand-700">
{idx + 1}
</span>
<span className="rounded-md bg-indigo-50 px-2 py-0.5 text-[11px] font-semibold text-indigo-700">
{questionTypeLabel(question.questionType)}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-[11px] font-semibold text-grayScale-600">
{question.difficultyLevel}
</span>
<span className="text-[11px] font-semibold text-grayScale-500">{question.points} pt</span>
</div>
<p className="mb-2 line-clamp-2 text-sm font-medium text-grayScale-800">
{question.questionText.trim() || `Question ${idx + 1}`}
</p>
{question.questionType === "MCQ" ? (
<div className="space-y-1">
{question.options.map((option, optionIdx) => (
<div
key={`${question.id}-option-${optionIdx}`}
className={`rounded px-2 py-1 text-xs ${
option.isCorrect
? "bg-green-50 font-medium text-green-700"
: "text-grayScale-500"
}`}
>
{option.text || `Option ${optionIdx + 1}`}
</div>
))}
</div>
) : null}
</div>
))
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:px-8 sm:py-5">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={() => void saveLesson("DRAFT")} disabled={saving}>
{saving ? "Saving..." : "Save as Draft"}
</Button>
<Button onClick={() => void saveLesson("PUBLISHED")} disabled={saving}>
<Rocket className="mr-2 h-4 w-4" />
{saving ? "Publishing..." : "Publish Now"}
</Button>
</div>
</div>
</Card>
) : null}
{currentStep === 4 && resultStatus ? (
<div className="mx-auto flex max-w-xl flex-col items-center py-16 text-center">
<div className={`mb-5 grid h-24 w-24 place-items-center rounded-full ${resultStatus === "success" ? "bg-gradient-to-br from-brand-200 to-brand-400" : "bg-gradient-to-br from-red-200 to-red-400"}`}>
<Check className="h-10 w-10 text-white" />
</div>
<h2 className="text-4xl font-bold tracking-tight text-grayScale-900">
{resultStatus === "success" ? "Lesson Published Successfully!" : "Lesson save failed"}
</h2>
<p className="mt-3 text-sm text-grayScale-500">{resultStatus === "success" ? "Your lesson is now active." : resultMessage}</p>
<div className="mt-8 w-full space-y-3">
<Button
className="h-11 w-full text-base"
onClick={() =>
navigate(lastSavedStatus === "PUBLISHED" ? "/content/human-language" : backTo)
}
>
Go back to Course
</Button>
{resultStatus === "success" ? (
<Button variant="outline" className="h-11 w-full text-base" onClick={() => navigate(0)}>
Add Another Lesson
</Button>
) : (
<Button variant="outline" className="h-11 w-full text-base" onClick={() => setCurrentStep(3)}>
Back to Review
</Button>
)}
</div>
</div>
) : null}
</div>
</div>
)
}

View File

@ -1,10 +1,8 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, Check } 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";
@ -15,33 +13,6 @@ const STEPS = [
{ id: 2, label: "Review & Publish" },
];
export type AddLessonFormData = {
title: string;
order: string;
description: string;
videoUrl: string;
thumbnailUrl: string;
};
const emptyForm = (): AddLessonFormData => ({
title: "",
order: "1",
description: "",
videoUrl: "",
thumbnailUrl: "",
});
function descriptionToApiPlain(html: string): string {
if (!html?.trim()) return "";
const t = html.trim();
if (!t.includes("<")) return t;
if (typeof document === "undefined") {
return t.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
const doc = new DOMParser().parseFromString(html, "text/html");
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
export function AddVideoFlow() {
const navigate = useNavigate();
const { level, courseId, moduleId } = useParams<{
@ -51,65 +22,24 @@ export function AddVideoFlow() {
}>();
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 [formData, setFormData] = useState({
title: "",
order: "1",
description: "",
thumbnail: null,
videoFile: null,
});
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const handlePublish = async () => {
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
toast.error("Invalid module");
return;
}
const title = formData.title.trim();
const videoUrl = formData.videoUrl.trim();
const thumbnail = formData.thumbnailUrl.trim();
if (!title) {
toast.error("Title is required");
return;
}
if (!videoUrl) {
toast.error("Video URL is required");
return;
}
if (!thumbnail) {
toast.error("Thumbnail is required");
return;
}
const description = descriptionToApiPlain(formData.description);
if (!description) {
toast.error("Description is required");
return;
}
setPublishing(true);
try {
await createModuleLesson(mid, {
title,
video_url: videoUrl,
thumbnail,
description,
});
toast.success("Lesson created");
setIsPublished(true);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create lesson";
toast.error(msg);
} finally {
setPublishing(false);
}
};
if (isPublished) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500 ">
{/* Success Icon Wrapper (Jagged Circle Style) */}
<div className="mb-12 relative scale-110">
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
<div className="relative">
@ -123,37 +53,35 @@ export function AddVideoFlow() {
</div>
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
Lesson created successfully
Video Published 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.
Your video is now live and available inside the selected module.
</p>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
<Button
onClick={() => navigate(backPath)}
onClick={() => navigate(`/new-content/learn-english/${level}`)}
className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95"
>
View module
Go back to Learn English
</Button>
<Button
onClick={() => {
setFormData(emptyForm());
setFormResetKey((k) => k + 1);
setFormData({
title: "",
order: "1",
description: "",
thumbnail: null,
videoFile: null,
});
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
Add Another Video
</Button>
</div>
</div>
@ -162,6 +90,7 @@ export function AddVideoFlow() {
return (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
{/* Header */}
<div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8">
<Link
@ -169,7 +98,7 @@ export function AddVideoFlow() {
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-500 transition-colors hover:text-brand-500 decoration-none"
>
<ArrowLeft className="h-4 w-4" />
Back to module
Back to Modules
</Link>
<Button
variant="outline"
@ -181,7 +110,7 @@ export function AddVideoFlow() {
</div>
<h1 className="text-2xl font-bold text-[#0F172A] mb-10">
Add new lesson
Add New Video
</h1>
<div className="mx-auto max-w-4xl mb-12">
@ -191,13 +120,13 @@ export function AddVideoFlow() {
/>
</div>
{/* Step Content */}
<div className="mx-auto max-w-7xl">
{currentStep === 1 && (
<VideoDetailStep
key={formResetKey}
formData={formData}
setFormData={setFormData}
onContinue={nextStep}
nextStep={nextStep}
/>
)}
@ -205,8 +134,7 @@ export function AddVideoFlow() {
<ReviewPublishStep
formData={formData}
prevStep={prevStep}
onPublish={() => void handlePublish()}
publishing={publishing}
setIsPublished={setIsPublished}
/>
)}
</div>

View File

@ -28,7 +28,7 @@ import {
import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner"
import { cn } from "../../lib/utils"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
type CourseWithCategory = Course & { category_name: string }
@ -230,7 +230,10 @@ export function AllCoursesPage() {
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 className="rounded-2xl bg-white shadow-sm p-6">
<SpinnerIcon className="h-10 w-10" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories</p>
</div>
)
}

View File

@ -1,26 +1,60 @@
import { Outlet } from "react-router-dom";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
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" },
]
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 mb-8">
<div className="flex items-center gap-3">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
Content Management
</h1>
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
View and manage practice content for courses, modules, and lessons
Manage courses, speaking exercises, practices, and questions
</p>
</div>
</div>
<ContentHierarchyList />
</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>
);
)
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { FolderOpen, RefreshCw, BookOpen, Plus, Trash2 } from "lucide-react"
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -11,11 +11,10 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { getCourseCategories, createCourseCategory, deleteCourseCategory } from "../../api/courses.api"
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
import { toast } from "sonner"
@ -30,8 +29,6 @@ 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)
@ -167,26 +164,12 @@ export function CourseCategoryPage() {
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View Sub-categories
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
</span>
<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>
<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>
</span>
</CardContent>
</Card>
</Link>
@ -352,7 +335,7 @@ export function CourseCategoryPage() {
if (createdCategoryId && pendingSubCategories.length > 0) {
await Promise.all(
pendingSubCategories.map((subName) =>
createCourseCategory({ name: subName, parent_id: createdCategoryId }),
createCourseCategory({ name: subName }),
),
)
}
@ -388,46 +371,6 @@ 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>
)
}

View File

@ -1,633 +1,166 @@
import { useCallback, useEffect, useState } from "react";
import {
ArrowLeft,
Plus,
Calendar,
Layers,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useState } from "react";
import { ArrowLeft, Plus, Calendar, Plane, Clock, Hand } 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";
const MODULES = [
{
id: "m1",
title: "Introduction Basics",
description: "Learn basic English words, phrases, and simple sentences.",
icon: Hand,
status: "Published",
gradient: "from-[#8E44AD] to-[#C39BD3]",
},
{
id: "m2",
title: "Daily Routines",
description: "Vocabulary related to waking up, and evening activities.",
icon: Clock,
status: "Draft",
gradient: "from-[#8E44AD] to-[#C39BD3]",
},
{
id: "m3",
title: "Travel Essentials",
description:
"Key phrases for airports, hotels, and asking for help while abroad.",
icon: Plane,
status: "Draft",
gradient: "from-[#8E44AD] to-[#C39BD3]",
},
];
import { 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 { level, courseId } = useParams<{ level: string; courseId: string }>();
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`}
to={`/new-content/learn-english/${level}/courses`}
className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500"
>
<ArrowLeft className="h-5 w-5" />
Back to Courses
Back to Levels
</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" />
{/* Hero Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
{courseId?.toUpperCase() || "A1"}
</h1>
<p className="text-grayScale-500 text-sm max-w-2xl font-medium">
Learn basic English words, phrases, and simple sentences for daily
situations.
</p>
</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>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="mt-4"
onClick={() => void loadPage()}
className="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=modules&courseId=${courseId}`,
)
}
>
Try again
<Calendar className="h-4 w-4" />
Add Practice
</Button>
<Button
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
onClick={() => setIsAddModuleOpen(true)}
>
<Plus className="h-4 w-4" />
Add Module
</Button>
</div>
) : (
<>
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
{displayTitle}
</h1>
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
{displayDescription}
</p>
</div>
<div className="flex items-center gap-4">
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
<Calendar className="h-4 w-4" />
Add Practice
</Button>
<Button
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
onClick={() => setIsAddModuleOpen(true)}
>
<Plus className="h-4 w-4" />
Add Module
</Button>
</div>
</div>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full rounded-full opacity-20"
style={{
background: "gray",
}}
/>
</div>
</div>
</div>
<AddModuleModal
isOpen={isAddModuleOpen}
onClose={() => setIsAddModuleOpen(false)}
courseId={courseIdNum}
onCreated={() => loadPage()}
/>
<AddModuleModal
isOpen={isAddModuleOpen}
onClose={() => setIsAddModuleOpen(false)}
/>
{/* Gradient Divider */}
<Dialog
open={editingModule !== null}
onOpenChange={(open) => {
if (!open && savingModuleEdit) return;
if (!open && editModuleIconUploadBusy) return;
if (!open) closeEditModule();
}}
{/* 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"
>
<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>
) : (
{/* Gradient Banner */}
<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} />
className={cn(
"h-36 w-full bg-gradient-to-b opacity-90 transition-transform duration-700",
module.gradient,
)}
/>
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
<div className="flex min-h-0 flex-1 gap-4">
<ModuleIconCircle iconSrc={iconSrc} index={index} />
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name}
</h3>
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
{module.description?.trim()
? module.description
: "—"}
</p>
</div>
</div>
<div className="mt-auto flex shrink-0 items-center gap-3">
<Button
variant="outline"
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
{
state: {
moduleName: module.name,
moduleDescription:
module.description?.trim() ?? "",
},
},
)
}
>
View Detail
</Button>
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
Publish Practice
</Button>
</div>
</div>
</Card>
);
})}
</div>
)}
{deletingModule && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">
Delete module
</h2>
<button
type="button"
onClick={() =>
!deletingModuleInFlight && setDeletingModule(null)
}
disabled={deletingModuleInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
<div className="p-2 pb-4 pt-4 flex-1 flex flex-col">
<div className="flex gap-4 mb-8">
{/* Icon Circle */}
<div
className={`h-12 w-12 rounded-full ${module.id === "m2" ? "bg-[#F8FAFC]" : "bg-[#f3e8ff]"} flex items-center justify-center p-3 flex-shrink-0 border border-purple-100/50`}
>
<module.icon
className={`h-6 w-6 ${module.id === "m2" ? "text-[#64748B]" : "text-brand-500"}`}
/>
</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.
{/* Content */}
<div className="space-y-1">
<h3 className="text-lg font-bold text-[#0F172A] tracking-tight">
{module.title}
</h3>
<p className="text-grayScale-400 font-medium text-[12px]">
{module.description}
</p>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
{/* Actions */}
<div className="flex items-center gap-3 mt-auto">
<Button
variant="outline"
className="flex-1 h-10 rounded-[6px] border-[#9E2891] text-[#9E2891] transition-all text-sm"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${module.id}`,
)
}
>
View Detail
</Button>
{module.status === "Published" ? (
<Button
type="button"
variant="outline"
onClick={() => setDeletingModule(null)}
disabled={deletingModuleInFlight}
className="w-full sm:w-auto"
disabled
className="flex-1 h-10 rounded-[6px] bg-[#D291BC] text-white opacity-100 cursor-default border-none shadow-none text-sm"
>
Cancel
Published
</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 className="flex-1 h-10 rounded-[6px] bg-brand-500 text-white shadow-md shadow-brand-500/10 text-sm">
Publish Practice
</Button>
</div>
)}
</div>
</div>
)}
</>
)}
</Card>
))}
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"
import {
BadgeCheck,
ChevronDown,
ChevronRight,
GripVertical,
@ -31,9 +32,9 @@ import { Badge } from "../../components/ui/badge"
import {
getCourseCategories,
getCoursesByCategory,
getSubModulesByCourse,
getVideosBySubModule,
getLearningPath,
getQuestionSetsByOwner,
getSubModuleEntryAssessment,
reorderCategories,
reorderCourses,
reorderSubModules,
@ -193,7 +194,9 @@ export function CourseFlowBuilderPage() {
const [practicesBySubCourse, setPracticesBySubCourse] = useState<Record<number, PracticeListItem[]>>(
{},
)
const [videosBySubCourse, setVideosBySubCourse] = useState<Record<number, LearningPathVideo[]>>({})
const [entryAssessmentBySubCourse, setEntryAssessmentBySubCourse] = useState<Record<number, QuestionSet | null>>(
{},
)
const [loading, setLoading] = useState(true)
const [loadingCourses, setLoadingCourses] = useState(false)
@ -257,9 +260,7 @@ export function CourseFlowBuilderPage() {
setLoadingCourses(true)
try {
const res = await getCoursesByCategory(selectedCategoryId)
const items = sortByDisplayOrder(
(res.data.data.courses ?? []).filter((course) => Number(course.category_id) === Number(selectedCategoryId)),
)
const items = sortByDisplayOrder(res.data.data.courses ?? [])
setCoursesByCategory((prev) => ({ ...prev, [selectedCategoryId]: items }))
setSelectedCourseId(items[0]?.id ?? null)
} catch {
@ -279,94 +280,47 @@ export function CourseFlowBuilderPage() {
const load = async () => {
setLoadingPath(true)
try {
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: [],
}))
const res = await getLearningPath(selectedCourseId)
const path = res.data.data
setLearningPath({
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,
...path,
sub_courses: sortByDisplayOrder(path.sub_courses ?? []),
})
if (subCourses.length === 0) {
setPracticesBySubCourse({})
setVideosBySubCourse({})
return
}
const [ownerResults, videoResults] = await Promise.all([
Promise.all(
// Practices source of truth: question sets by SUB_COURSE owner.
const subCourses = path.sub_courses ?? []
if (subCourses.length > 0) {
const ownerResults = await Promise.all(
subCourses.map(async (sc) => {
const setsRes = await getQuestionSetsByOwner("SUB_MODULE", sc.id)
const setsRes = await getQuestionSetsByOwner("SUB_COURSE", sc.id)
return [sc.id, mapPracticeSetsToPracticeItems((setsRes.data.data ?? []) as QuestionSet[])] as const
}),
),
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)
)
const practiceMap: Record<number, PracticeListItem[]> = {}
ownerResults.forEach(([subCourseId, practiceItems]) => {
practiceMap[subCourseId] = practiceItems
})
setPracticesBySubCourse(practiceMap)
} else {
setPracticesBySubCourse({})
}
} catch {
toast.error("Failed to load course flow detail.")
toast.error("Failed to load course sub-category learning path.")
setLearningPath(null)
} finally {
setLoadingPath(false)
}
}
load()
}, [selectedCourseId, activeCourses, selectedCategoryId, topLevelCategories])
}, [selectedCourseId])
const loadSubCoursePracticeAndEntry = async (subCourseId: number) => {
if (practicesBySubCourse[subCourseId] && videosBySubCourse[subCourseId]) return
if (practicesBySubCourse[subCourseId] && entryAssessmentBySubCourse[subCourseId] !== undefined) return
setLoadingPracticesBySubCourse((prev) => ({ ...prev, [subCourseId]: true }))
try {
const [setsRes, videosRes] = await Promise.allSettled([
getQuestionSetsByOwner("SUB_MODULE", subCourseId),
getVideosBySubModule(subCourseId),
const [setsRes, entryRes] = await Promise.allSettled([
getQuestionSetsByOwner("SUB_COURSE", subCourseId),
getSubModuleEntryAssessment(subCourseId),
])
// No practice sets is a valid empty-state scenario; do not toast for 404/empty.
@ -385,21 +339,20 @@ export function CourseFlowBuilderPage() {
[subCourseId]: mapPracticeSetsToPracticeItems(ownerSets),
}))
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) => ({
// 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) => ({
...prev,
[subCourseId]: videos,
[subCourseId]: entryAssessment,
}))
} catch {
toast.error("Failed to load practice sets for course.")
@ -741,7 +694,6 @@ 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
@ -771,12 +723,17 @@ export function CourseFlowBuilderPage() {
{subCourse.sub_level}
</Badge>
)}
{/* entry-assessment route is no longer guaranteed across deployments */}
{entryAssessmentBySubCourse[subCourse.id] && (
<span className="inline-flex items-center gap-1 text-emerald-600">
<BadgeCheck className="h-3.5 w-3.5" />
Entry assessment
</span>
)}
</p>
</div>
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
<Badge variant="secondary" className="text-[10px]">
{videos.length} videos / {practices.length} practices
{subCourse.videos.length} videos / {practices.length || subCourse.practice_count} practices
</Badge>
{expanded ? (
<ChevronDown className="h-4 w-4 text-grayScale-400" />
@ -798,16 +755,16 @@ export function CourseFlowBuilderPage() {
onDragEnd={(event) => onVideosDragEnd(subCourse.id, event)}
>
<SortableContext
items={videos.map((item) => item.id)}
items={subCourse.videos.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5">
{videos.length === 0 ? (
{subCourse.videos.length === 0 ? (
<p className="rounded-lg border border-dashed border-grayScale-200 px-2 py-2 text-[11px] text-grayScale-400">
No videos
</p>
) : (
videos.map((video) => (
subCourse.videos.map((video) => (
<SortableChip
key={video.id}
id={video.id}
@ -885,7 +842,7 @@ export function CourseFlowBuilderPage() {
</p>
<p>
Practices load from <code>/question-sets/by-owner</code> filtered by
<code> set_type=PRACTICE</code> and <code>owner_type=SUB_MODULE</code>.
<code> set_type=PRACTICE</code>; entry assessment loads from dedicated course endpoint.
</p>
</CardContent>
</Card>

View File

@ -1,4 +1,3 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
@ -7,15 +6,12 @@ import {
LayoutGrid,
PlayCircle,
ClipboardCheck,
Pencil,
Trash2,
ChevronRight,
ArrowRight,
X,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@ -26,15 +22,6 @@ import {
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import uploadIcon from "../../assets/icons/upload.png";
import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import {
createExamPrepCatalogUnit,
updateExamPrepCatalogUnit,
deleteExamPrepCatalogUnit,
getExamPrepCatalogUnits,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
export function CourseManagementPage() {
const navigate = useNavigate();
@ -42,38 +29,6 @@ export function CourseManagementPage() {
programType: string;
courseId: string;
}>();
const catalogCourseId = Number(courseId);
const [addUnitOpen, setAddUnitOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [units, setUnits] = useState<
Array<{
id: number;
name: string;
description: string;
thumbnail: string;
sortOrder: number;
modules: number;
lessons: number;
practices: number;
gradient: string;
}>
>([]);
const [unitsLoading, setUnitsLoading] = useState(false);
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null);
const [deletingUnit, setDeletingUnit] = useState(false);
// Mock data for display titles
const courseTitles: Record<string, string> = {
@ -84,289 +39,41 @@ export function CourseManagementPage() {
const courseDisplayName =
courseTitles[courseId || ""] || "Duolingo English Test";
const loadUnits = useCallback(async () => {
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
setUnits([]);
return;
}
setUnitsLoading(true);
try {
const response = await getExamPrepCatalogUnits(catalogCourseId, {
limit: 20,
offset: 0,
});
const rows = response.data?.data?.units;
const list = Array.isArray(rows) ? rows : [];
setUnits(
list.map((row, index) => ({
id: Number(row.id),
name: row.name?.trim() || `Unit ${row.id}`,
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
modules: Number(row.modules_count ?? 0),
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
practices: Number(row.practices_count ?? 0),
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
})),
);
} catch (error) {
console.error(error);
toast.error("Failed to load units");
setUnits([]);
} finally {
setUnitsLoading(false);
}
}, [catalogCourseId]);
useEffect(() => {
void loadUnits();
}, [loadUnits]);
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
const isMinioUrl = (value: string) => {
try {
const url = new URL(value);
return url.host === "s3.yimaruacademy.com";
} catch {
return false;
}
};
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) return "";
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
const uploaded = await uploadImageFile(trimmed);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
return uploadedUrl;
};
const clearCreateUnitForm = () => {
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
}
};
const handleCreateUnitThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
const maxBytes = 5 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
return;
}
setUploadingThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
setCreateThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const handleCreateUnit = async () => {
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
toast.error("Invalid catalog course");
return;
}
const name = createName.trim();
if (!name) {
toast.error("Unit name is required");
return;
}
setCreating(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
const response = await createExamPrepCatalogUnit(catalogCourseId, {
name,
description: createDescription.trim() || null,
thumbnail: minioThumbnail || null,
});
void response;
await loadUnits();
toast.success("Unit created");
clearCreateUnitForm();
setAddUnitOpen(false);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create unit";
toast.error(message);
} finally {
setCreating(false);
}
};
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setCreateThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingEditThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setEditThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const openEditUnit = (unit: (typeof units)[number]) => {
setEditingUnitId(unit.id);
setEditName(unit.name ?? "");
setEditDescription(unit.description ?? "");
setEditThumbnail(unit.thumbnail ?? "");
setEditSortOrder(String(unit.sortOrder ?? 1));
};
const closeEditUnit = () => {
if (savingEdit || uploadingEditThumbnail) return;
setEditingUnitId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
};
const handleEditUnitThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
setUploadingEditThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
setEditThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditUnit = async () => {
if (!editingUnitId) return;
const name = editName.trim();
if (!name) {
toast.error("Unit name is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
return;
}
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepCatalogUnit(editingUnitId, {
name,
description: editDescription.trim() || null,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
});
await loadUnits();
toast.success("Unit updated");
closeEditUnit();
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update unit";
toast.error(message);
} finally {
setSavingEdit(false);
}
};
const handleDeleteUnit = async () => {
if (!deletingUnitId) return;
setDeletingUnit(true);
try {
await deleteExamPrepCatalogUnit(deletingUnitId);
await loadUnits();
toast.success("Unit deleted");
setDeletingUnitId(null);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete unit";
toast.error(message);
} finally {
setDeletingUnit(false);
}
};
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">
@ -391,51 +98,29 @@ export function CourseManagementPage() {
</div>
<div className="flex items-center gap-3 pt-2">
<Dialog
open={addUnitOpen}
onOpenChange={(open) => {
if (!open && (creating || uploadingThumbnail)) return;
setAddUnitOpen(open);
}}
>
<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="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<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 Unit
Create Courses
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Unit Name
</label>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
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"
disabled={creating || uploadingThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Short unit description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating || uploadingThumbnail}
/>
</div>
@ -443,20 +128,7 @@ export function CourseManagementPage() {
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<input
ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateUnitThumbnailFile(e)}
disabled={creating || uploadingThumbnail}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createThumbnailFileInputRef.current?.click()}
disabled={creating || uploadingThumbnail}
>
<div className="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
@ -467,62 +139,31 @@ export function CourseManagementPage() {
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
Click to upload
</span>{" "}
<span className="text-grayScale-500">
or paste a URL below
or drag and drop
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
JPG, PNG (MAX 1 MB)
</p>
</div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadCreateThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating || uploadingThumbnail}
/>
</div>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={creating || uploadingThumbnail}
onClick={clearCreateUnitForm}
>
Cancel
</Button>
</DialogClose>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creating || uploadingThumbnail}
onClick={() => void handleCreateUnit()}
>
{creating ? "Creating..." : "Create Unit"}
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
Create Courses
</Button>
</div>
</div>
@ -559,61 +200,16 @@ export function CourseManagementPage() {
{/* Grid of Units */}
<div className="flex flex-wrap gap-4 pt-4">
{unitsLoading ? (
<p className="text-sm text-grayScale-500">Loading units...</p>
) : units.length === 0 ? (
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No units for this course yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first unit to start organizing modules, lessons, and practices.
</p>
</div>
) : (
units.map((unit) => (
{units.map((unit) => (
<Card
key={unit.id}
className="group relative flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
className="group flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
>
<div className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto">
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
onClick={() => openEditUnit(unit)}
aria-label={`Edit ${unit.name}`}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
onClick={() => setDeletingUnitId(unit.id)}
aria-label={`Delete ${unit.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Gradient Header */}
<div
className="relative h-36 w-full overflow-hidden transition-transform duration-500"
className="h-36 w-full transition-transform duration-500 "
style={{ background: unit.gradient }}
>
{unit.thumbnail ? (
<ResolvedImage
src={unit.thumbnail}
alt={`${unit.name} thumbnail`}
className="h-full w-full object-cover"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
) : null}
</div>
/>
<div className="p-4 flex flex-col flex-1 space-y-6">
<div className="space-y-3 flex-1">
@ -636,7 +232,7 @@ export function CourseManagementPage() {
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
<span className="text-[12px] font-bold">
{unit.lessons} Lessons
{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">
@ -661,169 +257,8 @@ export function CourseManagementPage() {
</Button>
</div>
</Card>
))
)}
))}
</div>
<Dialog
open={editingUnitId !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail)) return;
if (!open) closeEditUnit();
}}
>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Edit Unit
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Unit Name</label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditUnitThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editThumbnailFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{editThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={editThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadEditThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={savingEdit || uploadingEditThumbnail}
onClick={closeEditUnit}
>
Cancel
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={savingEdit || uploadingEditThumbnail}
onClick={() => void handleSaveEditUnit()}
>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deletingUnitId !== null}
onOpenChange={(open) => {
if (!open && !deletingUnit) setDeletingUnitId(null);
}}
>
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
<div className="bg-white">
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Delete Unit
</DialogTitle>
</DialogHeader>
<div className="px-6 py-6 text-sm text-grayScale-600">
Are you sure you want to delete this unit? This action cannot be undone.
</div>
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setDeletingUnitId(null)}
disabled={deletingUnit}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={() => void handleDeleteUnit()}
disabled={deletingUnit}
>
{deletingUnit ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,30 +1,40 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
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";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png";
import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import { VideoCard } from "./components/VideoCard";
import {
createExamPrepModuleLesson,
updateExamPrepModuleLesson,
deleteExamPrepModuleLesson,
getExamPrepModuleLessons,
} from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
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 = [
{
@ -51,402 +61,19 @@ export function CourseModuleDetailPage() {
unitId: string;
moduleId: string;
}>();
const parsedModuleId = Number(moduleId);
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [lessonsLoading, setLessonsLoading] = useState(false);
const [lessons, setLessons] = useState<
Array<{
id: number;
title: string;
videoUrl: string;
description: string;
thumbnail: string;
sortOrder: number;
gradient: string;
}>
>([]);
const [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [creatingLesson, setCreatingLesson] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const [uploadingVideo, setUploadingVideo] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const createVideoFileInputRef = useRef<HTMLInputElement>(null);
const [editingLessonId, setEditingLessonId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editVideoUrl, setEditVideoUrl] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
const [uploadingEditVideo, setUploadingEditVideo] = useState(false);
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const editVideoFileInputRef = useRef<HTMLInputElement>(null);
const [deletingLessonId, setDeletingLessonId] = useState<number | null>(null);
const [deletingLesson, setDeletingLesson] = useState(false);
const [activeFilter, setActiveFilter] = useState("All");
const moduleTitle = "Module 1: Basic Phrases";
const moduleDescription = "Learn essential phrases for daily conversations.";
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
const isMinioUrl = (value: string) => {
try {
const url = new URL(value);
return url.host === "s3.yimaruacademy.com";
} catch {
return false;
}
};
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) return "";
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
const uploaded = await uploadImageFile(trimmed);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
return uploadedUrl;
};
const loadLessons = useCallback(async () => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
setLessons([]);
return;
}
setLessonsLoading(true);
try {
const response = await getExamPrepModuleLessons(parsedModuleId, {
limit: 20,
offset: 0,
});
const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : [];
setLessons(
list.map((row, index) => ({
id: Number(row.id),
title: row.title?.trim() || `Lesson ${row.id}`,
videoUrl: row.video_url?.trim() || "",
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
})),
);
} catch (error) {
console.error(error);
toast.error("Failed to load lessons");
setLessons([]);
} finally {
setLessonsLoading(false);
}
}, [parsedModuleId]);
useEffect(() => {
if (activeTab !== "video") return;
void loadLessons();
}, [activeTab, loadLessons]);
const clearCreateLessonForm = () => {
setCreateTitle("");
setCreateVideoUrl("");
setCreateThumbnail("");
setCreateDescription("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
}
if (createVideoFileInputRef.current) {
createVideoFileInputRef.current.value = "";
}
};
const handleCreateLessonVideoFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
setUploadingVideo(true);
try {
const res = await uploadVideoFile(file, {
title: createTitle.trim() || "Lesson video",
description: createDescription.trim() || undefined,
});
const finalUrl =
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
if (!finalUrl) throw new Error("Upload did not return a video URL");
setCreateVideoUrl(finalUrl);
toast.success("Video uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(message);
} finally {
setUploadingVideo(false);
}
};
const handleCreateLessonThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
setUploadingThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
setCreateThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setCreateThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const handleCreateLesson = async () => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module");
return;
}
const title = createTitle.trim();
const videoUrl = createVideoUrl.trim();
if (!title) {
toast.error("Lesson title is required");
return;
}
if (!videoUrl) {
toast.error("Video URL is required");
return;
}
setCreatingLesson(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
await createExamPrepModuleLesson(parsedModuleId, {
title,
video_url: videoUrl,
thumbnail: minioThumbnail || null,
description: createDescription.trim() || null,
});
await loadLessons();
toast.success("Lesson created");
clearCreateLessonForm();
setCreateLessonOpen(false);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create lesson";
toast.error(message);
} finally {
setCreatingLesson(false);
}
};
const openEditLesson = (lesson: (typeof lessons)[number]) => {
setEditingLessonId(lesson.id);
setEditTitle(lesson.title ?? "");
setEditVideoUrl(lesson.videoUrl ?? "");
setEditThumbnail(lesson.thumbnail ?? "");
setEditDescription(lesson.description ?? "");
setEditSortOrder(String(lesson.sortOrder ?? 1));
};
const closeEditLesson = () => {
if (savingEdit || uploadingEditThumbnail || uploadingEditVideo) return;
setEditingLessonId(null);
setEditTitle("");
setEditVideoUrl("");
setEditThumbnail("");
setEditDescription("");
setEditSortOrder("1");
};
const handleEditLessonVideoFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
setUploadingEditVideo(true);
try {
const res = await uploadVideoFile(file, {
title: editTitle.trim() || "Lesson video",
description: editDescription.trim() || undefined,
});
const finalUrl =
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
if (!finalUrl) throw new Error("Upload did not return a video URL");
setEditVideoUrl(finalUrl);
toast.success("Video uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(message);
} finally {
setUploadingEditVideo(false);
}
};
const handleEditLessonThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
setUploadingEditThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
setEditThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingEditThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setEditThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditLesson = async () => {
if (!editingLessonId) return;
const title = editTitle.trim();
if (!title) {
toast.error("Lesson title is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
return;
}
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepModuleLesson(editingLessonId, {
title,
video_url: editVideoUrl.trim() || null,
thumbnail: minioThumbnail || null,
description: editDescription.trim() || null,
sort_order: sortOrderNum,
});
await loadLessons();
toast.success("Lesson updated");
closeEditLesson();
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson";
toast.error(message);
} finally {
setSavingEdit(false);
}
};
const handleDeleteLesson = async () => {
if (!deletingLessonId) return;
setDeletingLesson(true);
try {
await deleteExamPrepModuleLesson(deletingLessonId);
await loadLessons();
toast.success("Lesson deleted");
setDeletingLessonId(null);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete lesson";
toast.error(message);
} finally {
setDeletingLesson(false);
}
};
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">
@ -483,188 +110,10 @@ export function CourseModuleDetailPage() {
<FileText className="h-5 w-5" />
Attach Practice
</Button>
<Dialog
open={createLessonOpen}
onOpenChange={(open) => {
if (!open && (creatingLesson || uploadingThumbnail || uploadingVideo))
return;
setCreateLessonOpen(open);
}}
>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
<Plus className="h-5 w-5" />
Add Lesson
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Lesson
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Lesson Title
</label>
<Input
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
placeholder="e.g. Intro lesson"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Video URL
</label>
<input
ref={createVideoFileInputRef}
type="file"
accept="video/*"
className="sr-only"
onChange={(e) => void handleCreateLessonVideoFile(e)}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createVideoFileInputRef.current?.click()}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingVideo ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
video from your computer
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
MP4, MOV, WEBM
</p>
</div>
</button>
<Input
value={createVideoUrl}
onChange={(e) => setCreateVideoUrl(e.target.value)}
placeholder="https://example.com/video"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional lesson description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<input
ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateLessonThumbnailFile(e)}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createThumbnailFileInputRef.current?.click()}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
or paste a URL below
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadCreateThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={clearCreateLessonForm}
>
Cancel
</Button>
</DialogClose>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson()}
>
{creatingLesson ? "Creating..." : "Create Lesson"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<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>
@ -679,7 +128,7 @@ export function CourseModuleDetailPage() {
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Lesson
Video
</button>
<button
onClick={() => setActiveTab("practice")}
@ -694,245 +143,40 @@ export function CourseModuleDetailPage() {
</button>
</div>
{/* Grid of Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
{activeTab === "video" ? (
lessonsLoading ? (
<p className="text-sm text-grayScale-500">Loading lessons...</p>
) : lessons.length === 0 ? (
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No lessons for this module yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first lesson to start building this module.
</p>
</div>
) : (
lessons.map((lesson) => (
<VideoCard
key={lesson.id}
title={lesson.title}
thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient}
hoverModuleActions
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}
/>
))
)
) : (
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
)}
{/* 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>
<Dialog
open={editingLessonId !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail || uploadingEditVideo))
return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Edit Lesson
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Lesson Title</label>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Video URL</label>
<input
ref={editVideoFileInputRef}
type="file"
accept="video/*"
className="sr-only"
onChange={(e) => void handleEditLessonVideoFile(e)}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editVideoFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditVideo ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
video from your computer
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
MP4, MOV, WEBM
</p>
</div>
</button>
<Input
value={editVideoUrl}
onChange={(e) => setEditVideoUrl(e.target.value)}
placeholder="https://example.com/video"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditLessonThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editThumbnailFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{editThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={editThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadEditThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
onClick={closeEditLesson}
>
Cancel
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
onClick={() => void handleSaveEditLesson()}
>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deletingLessonId !== null}
onOpenChange={(open) => {
if (!open && !deletingLesson) setDeletingLessonId(null);
}}
>
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
<div className="bg-white">
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Delete Lesson
</DialogTitle>
</DialogHeader>
<div className="px-6 py-6 text-sm text-grayScale-600">
Are you sure you want to delete this lesson? This action cannot be undone.
</div>
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setDeletingLessonId(null)}
disabled={deletingLesson}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={() => void handleDeleteLesson()}
disabled={deletingLesson}
>
{deletingLesson ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 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 PracticeCard({
function ContentCard({
title,
duration,
status,
@ -970,7 +214,9 @@ function PracticeCard({
/>
{status}
</div>
<div />
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-300 hover:text-grayScale-600 transition-colors">
<MoreVertical className="h-5 w-5" />
</button>
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
@ -978,7 +224,11 @@ function PracticeCard({
</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 text-xs">
<Button
variant="outline"
className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold flex items-center justify-center gap-2 text-xs hover:bg-grayScale-25"
>
<Edit2 className="h-4 w-4" />
Edit
</Button>
<Button
@ -996,4 +246,3 @@ function PracticeCard({
</Card>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
import { Plus, ArrowRight } 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 {
@ -11,250 +9,33 @@ import {
DialogTitle,
DialogDescription,
DialogTrigger,
DialogFooter,
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { Select } from "../../components/ui/select";
import uploadIcon from "../../assets/icons/upload.png";
import 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]);
const levels = [
{
id: "beginner",
title: "Beginner",
description:
"Designed for learners starting from scratch. Focuses on simple grammar, and everyday communication.",
},
{
id: "intermediate",
title: "Intermediate",
description:
"For learners who can communicate at a basic level and want to improve fluency, accuracy, and confidence.",
},
{
id: "advanced",
title: "Advanced",
description:
"Targets advanced learners aiming for professional, academic, and complex conversational English.",
},
];
return (
<div className="space-y-8">
@ -265,163 +46,115 @@ export function LearnEnglishPage() {
Learn English
</h1>
<p className="mt-1 text-sm text-grayScale-500">
Manage learning content by program cards load from the server
Manage learning content by level
</p>
</div>
<Dialog open={createOpen} onOpenChange={handleCreateDialogOpenChange}>
<Dialog>
<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">
<DialogContent className="max-w-2xl gap-0 border-none p-0">
<DialogHeader className="p-8 pb-4">
<DialogTitle className="text-2xl font-bold text-grayScale-700">
Add New Program
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a learning program to group courses by learner level
</DialogDescription>
</DialogHeader>
{/* Gradient Divider */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<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>
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</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>
<form className="space-y-6 p-8 pt-4">
<div className="space-y-2">
<label className="text-[15px] text-grayScale-700">
Program Name
</label>
<Input
placeholder="e.g. Beginner"
className="h-12 rounded-xl ring-0"
/>
</div>
<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">
Description
</label>
<Input
placeholder="Short description explaining who this program is for"
className="h-12 rounded-xl"
/>
</div>
<div className="space-y-2">
<label className="text-[15px] text-grayScale-700">
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">
<div className="space-y-2">
<label className="text-[15px] text-grayScale-700">
Program Order
</label>
<Select className="h-12 rounded-xl">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-[15px] text-grayScale-700">
Thumbnail
</label>
<div className="relative group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 transition-all ">
<div className="mb-4">
<img
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
className="h-12 rounded-xl"
placeholder="https://…"
disabled={createSaving || createUploadingThumbnail}
/>
<p className="text-sm">
<span className="font-bold text-[#9E2891]">
Click to upload
</span>{" "}
<span className="text-grayScale-500">
or drag and drop
</span>
</p>
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
JPG, PNG (MAX 1 MB)
</p>
</div>
</div>
</div>
<div className="flex 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"}
<div className="flex justify-end gap-3 pt-4">
<DialogClose asChild>
<Button
variant="outline"
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
>
Cancel
</Button>
</DialogClose>
<Button className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
Create Program
</Button>
</div>
</form>
@ -444,263 +177,40 @@ export function LearnEnglishPage() {
</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()}
{/* Cards Grid */}
<div className="flex flex-warp gap-10">
{levels.map((level) => (
<Card
key={level.title}
className="group w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
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>
{/* Gradient Header */}
<div
className="h-32 w-full"
style={{
background:
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
}}
/>
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
<div className="flex-1">
<h3 className="text-xl font-bold text-grayScale-700">
{level.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-grayScale-500">
{level.description}
</p>
</div>
<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"}
<Link to={`/new-content/learn-english/${level.id}/courses`}>
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
View Courses
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</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>
)}
</Link>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import {
ArrowLeft,
Video,
@ -7,39 +7,42 @@ import {
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 { Link, useNavigate, useParams } from "react-router-dom";
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_VIDEOS = [
{
id: "v1",
title: "1.1 Introduction to Formal Greetings",
duration: "08:45",
status: "Draft",
thumbnailGradient: "from-[#CBD5E1] to-[#94A3B8]",
},
{
id: "v2",
title: "1.2 Understanding Email Structure",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#DBEAFE] to-[#93C5FD]",
},
{
id: "v3",
title: "1.3 Common Business Idioms",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#FEF3C7] to-[#FCD34D]",
},
{
id: "v4",
title: "1.4 Video Conference Etiquette",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#FCE7F3] to-[#F9A8D4]",
},
];
const MOCK_PRACTICES = [
{
@ -72,15 +75,8 @@ const MOCK_PRACTICES = [
},
];
type ModuleDetailState = {
moduleName?: string;
moduleDescription?: string;
};
export function ModuleDetailPage() {
const navigate = useNavigate();
const location = useLocation();
const navState = location.state as ModuleDetailState | null;
const { level, courseId, moduleId } = useParams<{
level: string;
courseId: string;
@ -88,211 +84,14 @@ export function ModuleDetailPage() {
}>();
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 [videos] = useState(MOCK_VIDEOS);
const [practices] = useState(MOCK_PRACTICES);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
>(null);
const [moduleListResolved, setModuleListResolved] = useState(
Boolean(navState?.moduleName?.trim()),
);
const moduleTitleFallback =
const moduleTitle =
moduleId
?.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);
}
};
.join(" ") || "Business English Fundamentals";
return (
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
@ -311,10 +110,12 @@ export function ModuleDetailPage() {
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
<div className="">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
{displayModuleName}
Module 3: {moduleTitle}
</h1>
<p className="text-grayScale-500 text-[14px] max-w-2xl">
{displayModuleDescription}
This module covers essential vocabulary and phrases used in modern
business environments, including email etiquette and meeting
protocols.
</p>
</div>
<div className="flex items-center gap-3">
@ -341,7 +142,7 @@ export function ModuleDetailPage() {
<div className="h-4 w-4 flex items-center justify-center">
<span className="text-xl leading-none font-light">+</span>
</div>
Add Lesson
Add Video
</Button>
</div>
</div>
@ -358,7 +159,7 @@ export function ModuleDetailPage() {
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Lesson
Video
</button>
<button
onClick={() => setActiveTab("practice")}
@ -377,27 +178,14 @@ export function ModuleDetailPage() {
{/* 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 ? (
videos.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
{videos.map((video) => (
<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)}
key={video.id}
{...(video as any)}
onEdit={() => console.log("Edit", video.id)}
onPublish={() => console.log("Publish", video.id)}
/>
))}
</div>
@ -409,11 +197,11 @@ export function ModuleDetailPage() {
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
No lessons in this module yet
No videos added to this module yet
</h2>
<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.
Videos are a great way to engage students. Start building your
module by adding your first video lesson now.
</p>
<Button
variant="outline"
@ -425,7 +213,7 @@ export function ModuleDetailPage() {
}
>
<Video className="h-5 w-5" />
Add Lesson
Add Video
</Button>
</div>
)
@ -463,149 +251,6 @@ export function ModuleDetailPage() {
</div>
)}
</div>
<Dialog
open={editingLesson !== null}
onOpenChange={(open) => {
if (!open && (savingLessonEdit || lessonMediaUploadBusy)) return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-title"
>
Title
</label>
<Input
id="edit-lesson-title"
value={editLessonTitle}
onChange={(e) => setEditLessonTitle(e.target.value)}
disabled={savingLessonEdit}
/>
</div>
<LessonMediaUploadField
kind="video"
value={editLessonVideoUrl}
onChange={setEditLessonVideoUrl}
disabled={savingLessonEdit}
onUploadBusyChange={setVideoUploadBusy}
/>
<LessonMediaUploadField
kind="thumbnail"
value={editLessonThumbnail}
onChange={setEditLessonThumbnail}
disabled={savingLessonEdit}
onUploadBusyChange={setThumbUploadBusy}
/>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-desc"
>
Description
</label>
<Textarea
id="edit-lesson-desc"
value={editLessonDescription}
onChange={(e) => setEditLessonDescription(e.target.value)}
rows={4}
disabled={savingLessonEdit}
className="min-h-[100px] resize-y"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={closeEditLesson}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void handleSaveLessonEdit()}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
{savingLessonEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deletingLesson && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">
Delete lesson
</h2>
<button
type="button"
onClick={() =>
!deletingLessonInFlight && setDeletingLesson(null)
}
disabled={deletingLessonInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{deletingLesson.title}
</span>
? This cannot be undone.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingLesson(null)}
disabled={deletingLessonInFlight}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deletingLessonInFlight}
onClick={() => void handleConfirmDeleteLesson()}
>
{deletingLessonInFlight ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -58,13 +58,7 @@ const typeColors: Record<QuestionType, string> = {
}
export function PracticeQuestionsPage() {
const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
categoryId: string
courseId: string
subModuleId?: string
levelId?: string
practiceId?: string
}>()
const { categoryId, courseId, subModuleId, practiceId } = useParams()
const location = useLocation()
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
@ -108,14 +102,11 @@ export function PracticeQuestionsPage() {
const [saveError, setSaveError] = useState<string | null>(null)
const backLink = useMemo(() => {
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) {
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}`
}, [location.pathname, categoryId, courseId, subModuleId, levelId])
}, [location.pathname, categoryId, courseId, subModuleId])
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
if (type === "TRUE_FALSE") {

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,3 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
@ -7,13 +6,11 @@ import {
ClipboardList,
ListChecks,
ChevronRight,
Pencil,
Trash2,
X,
Upload,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@ -23,51 +20,12 @@ import {
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import {
createExamPrepCatalogCourse,
getExamPrepCatalogCourses,
updateExamPrepCatalogCourse,
deleteExamPrepCatalogCourse,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import { Select } from "../../components/ui/select";
import uploadIcon from "../../assets/icons/upload.png";
export function ProgramDetailPage() {
const navigate = useNavigate();
const { programType } = useParams<{ programType: string }>();
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [createdCourses, setCreatedCourses] = useState<
{
id: number;
name: string;
description: string;
thumbnail?: string | null;
sortOrder: number;
unitsCount: number;
modulesCount: number;
lessonsCount: number;
}[]
>([]);
const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [deletingCourseId, setDeletingCourseId] = useState<number | null>(null);
const [deletingCourse, setDeletingCourse] = useState(false);
// Mock data for "proficiency" program type
const programs: Record<string, any> = {
@ -75,7 +33,45 @@ export function ProgramDetailPage() {
title: "English Proficiency Exams",
description:
"Manage exam-based learning programs such as Duolingo and IELTS.",
courses: [],
courses: [
{
id: "duolingo",
name: "Duolingo English Test",
description:
"Adaptive exam-style practice for speaking, writing, reading, and listening.",
coursesCount: 6,
questionTypesCount: 13,
logo: (
<div className="h-14 w-14 rounded-full bg-[#FFB800] flex items-center justify-center relative overflow-hidden">
{/* Simple Duolingo-like representation if image not available */}
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent" />
<div className="h-8 w-8 bg-white rounded-full flex items-center justify-center">
<div className="h-4 w-4 bg-[#FFB800] rounded-sm transform rotate-45" />
</div>
</div>
),
buttonText: "Manage Detail",
},
{
id: "ielts",
name: "IELTS Academic",
description:
"Full preparation for IELTS speaking, writing, listening, and reading.",
coursesCount: 4,
questionTypesCount: 18,
logo: (
<div className="flex items-center gap-1">
<span className="text-[28px] font-black tracking-tighter text-[#E11D48] ">
IELTS
</span>
<span className="text-[8px] font-bold text-[#E11D48] mt-2 tracking-widest uppercase">
</span>
</div>
),
buttonText: "View Detail",
},
],
},
"skill-based": {
title: "Skill-Based Courses",
@ -88,327 +84,6 @@ export function ProgramDetailPage() {
const currentProgram =
programs[programType || "proficiency"] || programs.proficiency;
const loadCatalogCourses = useCallback(async () => {
if (programType !== "proficiency") return;
setCatalogLoading(true);
try {
const response = await getExamPrepCatalogCourses({ limit: 20, offset: 0 });
const rows = response.data?.data?.catalog_courses;
const list = Array.isArray(rows) ? rows : [];
setCreatedCourses(
list.map((row) => ({
id: Number(row.id),
name: row.name?.trim() || `Course ${row.id}`,
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0),
modulesCount: Number(row.modules_count ?? 0),
lessonsCount: Number(row.lessons_count ?? 0),
})),
);
} catch (error) {
console.error(error);
toast.error("Failed to fetch catalog courses");
setCreatedCourses([]);
} finally {
setCatalogLoading(false);
}
}, [programType]);
useEffect(() => {
void loadCatalogCourses();
}, [loadCatalogCourses]);
const proficiencyCourses = [
...currentProgram.courses,
...createdCourses.map((course) => ({
id: course.id,
name: course.name,
description: course.description,
units_count: course.unitsCount,
modules_count: course.modulesCount,
lessons_count: course.lessonsCount,
logo: null,
thumbnail: course.thumbnail ?? "",
sort_order: course.sortOrder,
buttonText: "View Detail",
})),
];
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
const isMinioUrl = (value: string) => {
try {
const url = new URL(value);
return url.host === "s3.yimaruacademy.com";
} catch {
return false;
}
};
const autoUploadThumbnailUrlIfNeeded = async (rawValue: string) => {
const candidate = rawValue.trim();
if (!candidate) return;
if (!isHttpUrl(candidate)) return;
if (isMinioUrl(candidate)) return;
if (uploadingThumbnail || creating) return;
setUploadingThumbnail(true);
try {
const uploaded = await uploadImageFile(candidate);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) {
throw new Error("Failed to upload thumbnail URL to MinIO");
}
setCreateThumbnail(uploadedUrl);
setCreateThumbnailFromUpload(true);
toast.success("Thumbnail URL uploaded to MinIO");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail URL";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) return "";
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
const uploaded = await uploadImageFile(trimmed);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) {
throw new Error("Failed to upload thumbnail URL to MinIO");
}
return uploadedUrl;
};
const handleCreateCourse = async () => {
if (programType !== "proficiency") {
toast.error("Create Course is supported only for proficiency catalog.");
return;
}
const name = createName.trim();
if (!name) {
toast.error("Course name is required");
return;
}
setCreating(true);
try {
let thumbnailToSend: string | null = createThumbnail.trim() || null;
if (
thumbnailToSend &&
!createThumbnailFromUpload &&
isHttpUrl(thumbnailToSend) &&
!isMinioUrl(thumbnailToSend)
) {
const uploaded = await uploadImageFile(thumbnailToSend);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) {
throw new Error("Failed to upload thumbnail URL to MinIO");
}
thumbnailToSend = uploadedUrl;
}
const response = await createExamPrepCatalogCourse({
name,
description: createDescription.trim() || null,
thumbnail: thumbnailToSend,
});
const row = response.data?.data;
if (!row?.id) {
throw new Error("Missing created course payload");
}
setCreatedCourses((prev) => [
{
id: row.id,
name: row.name ?? name,
description: row.description?.trim() || createDescription.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0),
modulesCount: Number(row.modules_count ?? 0),
lessonsCount: Number(row.lessons_count ?? 0),
},
...prev,
]);
await loadCatalogCourses();
toast.success("Course created");
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
setCreateThumbnailFromUpload(false);
setCreateOpen(false);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create course";
toast.error(message);
} finally {
setCreating(false);
}
};
const openEditCourse = (course: (typeof proficiencyCourses)[number]) => {
const idNum = Number(course.id);
if (!Number.isFinite(idNum)) return;
setEditingCourseId(idNum);
setEditName(String(course.name ?? ""));
setEditDescription(String(course.description ?? ""));
setEditThumbnail(String(course.thumbnail ?? ""));
setEditSortOrder(String(course.sort_order ?? 1));
};
const closeEditCourse = () => {
if (savingEdit || uploadingEditThumbnail) return;
setEditingCourseId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
};
const handleEditThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
setUploadingEditThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
setEditThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditCourse = async () => {
if (!editingCourseId) return;
const name = editName.trim();
if (!name) {
toast.error("Course name is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
return;
}
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
const response = await updateExamPrepCatalogCourse(editingCourseId, {
name,
description: editDescription.trim() || null,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
});
const row = response.data?.data;
setCreatedCourses((prev) =>
prev.map((course) =>
course.id === editingCourseId
? {
...course,
name: row?.name ?? name,
description: row?.description?.trim() || editDescription.trim() || "—",
thumbnail: row?.thumbnail?.trim() || null,
sortOrder: Number(row?.sort_order ?? sortOrderNum),
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
modulesCount: Number(row?.modules_count ?? course.modulesCount ?? 0),
lessonsCount: Number(row?.lessons_count ?? course.lessonsCount ?? 0),
}
: course,
),
);
await loadCatalogCourses();
toast.success("Course updated");
closeEditCourse();
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update course";
toast.error(message);
} finally {
setSavingEdit(false);
}
};
const handleDeleteCourse = async () => {
if (!deletingCourseId) return;
setDeletingCourse(true);
try {
await deleteExamPrepCatalogCourse(deletingCourseId);
await loadCatalogCourses();
toast.success("Course deleted");
setDeletingCourseId(null);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete course";
toast.error(message);
} finally {
setDeletingCourse(false);
}
};
const handleCreateCourseThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
const maxBytes = 5 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
return;
}
setUploadingThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) {
throw new Error("Upload did not return a file URL");
}
setCreateThumbnail(url);
setCreateThumbnailFromUpload(true);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
@ -432,136 +107,84 @@ export function ProgramDetailPage() {
</div>
<div className="flex items-center gap-3 pt-2">
<Dialog
open={createOpen}
onOpenChange={(open) => {
if (!open && (creating || uploadingThumbnail)) return;
setCreateOpen(open);
}}
>
<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="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
<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="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
<div className="p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Name
</label>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
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"
disabled={creating}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
Course Order
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating}
/>
<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>
<input
ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
disabled={creating || uploadingThumbnail}
/>
<button
type="button"
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
disabled={creating || uploadingThumbnail}
onClick={() => createThumbnailFileInputRef.current?.click()}
>
<div className="flex flex-col items-center justify-center">
<div className="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="" className="h-10 w-10" />
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="font-bold text-brand-500">
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
<span className="text-brand-500 font-bold hover:underline">
Click to upload
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
<span className="text-grayScale-500">
or drag and drop
</span>
</p>
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
JPG, PNG (MAX 5 MB)
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 1 MB)
</p>
</div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => {
setCreateThumbnail(e.target.value);
setCreateThumbnailFromUpload(false);
}}
onBlur={(e) => {
void autoUploadThumbnailUrlIfNeeded(e.target.value);
}}
onPaste={(e) => {
const pasted = e.clipboardData.getData("text");
if (!pasted) return;
setCreateThumbnail(pasted);
setCreateThumbnailFromUpload(false);
void autoUploadThumbnailUrlIfNeeded(pasted);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating || uploadingThumbnail}
/>
</div>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={creating || uploadingThumbnail}
>
Cancel
</Button>
</DialogClose>
<Button
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creating || uploadingThumbnail}
onClick={() => void handleCreateCourse()}
>
{creating ? "Creating..." : "Create Course"}
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
Create Program
</Button>
</div>
</div>
@ -598,70 +221,13 @@ export function ProgramDetailPage() {
{/* Cards Grid */}
<div className="flex flex-wrap gap-8 mt-10">
{programType === "proficiency" && catalogLoading ? (
<p className="text-sm text-grayScale-500">Loading catalog courses...</p>
) : null}
{(programType === "proficiency"
? proficiencyCourses
: currentProgram.courses
).length === 0 && !catalogLoading ? (
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No catalog courses yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first exam-prep catalog course to start organizing units, modules, and lessons.
</p>
</div>
) : (
(programType === "proficiency"
? proficiencyCourses
: currentProgram.courses
).map((course: any) => (
<Card
key={course.id}
className="group relative bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
>
{programType === "proficiency" ? (
<div className="absolute right-3 top-3 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto">
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
onClick={() => openEditCourse(course)}
aria-label={`Edit ${course.name}`}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
onClick={() => setDeletingCourseId(Number(course.id))}
aria-label={`Delete ${course.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
{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.thumbnail ? (
<ResolvedImage
src={course.thumbnail}
alt={course.name}
className="h-14 w-14 rounded-full object-cover"
/>
) : course.logo ? (
course.logo
) : (
<div className="h-14 w-14 rounded-full bg-brand-50 text-brand-600 grid place-items-center text-xs font-bold">
{String(course.name ?? "C").slice(0, 2).toUpperCase()}
</div>
)}
</div>
<div className="h-16 flex items-center">{course.logo}</div>
{/* Content */}
<div className="space-y-4 pt-2 flex-1">
@ -678,19 +244,13 @@ export function ProgramDetailPage() {
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
<ClipboardList className="h-3 w-3 text-grayScale-400" />
<span className="text-[12px] ">
{Number(course.units_count ?? 0)} Units
{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] ">
{Number(course.modules_count ?? 0)} Modules
</span>
</div>
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
<ListChecks className="h-3 w-3 text-grayScale-400" />
<span className="text-[12px] ">
{Number(course.lessons_count ?? 0)} Lessons
{course.questionTypesCount} Question Types
</span>
</div>
</div>
@ -705,166 +265,9 @@ export function ProgramDetailPage() {
{course.buttonText}
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
</Button>
</Card>
))
)}
</Card>
))}
</div>
<Dialog
open={editingCourseId !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail)) return;
if (!open) closeEditCourse();
}}
>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Edit Course
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Name</label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail}
/>
<button
type="button"
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => editThumbnailFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail}
>
<div className="flex flex-col items-center justify-center">
<div className="mb-4">
<img src={uploadIcon} alt="" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="font-bold text-brand-500">
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{editThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={editThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
placeholder="https://..."
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
onClick={closeEditCourse}
disabled={savingEdit || uploadingEditThumbnail}
>
Cancel
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
onClick={() => void handleSaveEditCourse()}
disabled={savingEdit || uploadingEditThumbnail}
>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deletingCourseId !== null}
onOpenChange={(open) => {
if (!open && !deletingCourse) setDeletingCourseId(null);
}}
>
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
<div className="bg-white">
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Delete Course
</DialogTitle>
</DialogHeader>
<div className="px-6 py-6 text-sm text-grayScale-600">
Are you sure you want to delete this course? This action cannot be undone.
</div>
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setDeletingCourseId(null)}
disabled={deletingCourse}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={() => void handleDeleteCourse()}
disabled={deletingCourse}
>
{deletingCourse ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Mic, Plus, Trash2, Upload } from "lucide-react"
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@ -1926,7 +1926,7 @@ export function SpeakingPage() {
className="gap-1.5"
>
{uploadingIntroVideo ? (
<SpinnerIcon className="h-4 w-4" alt="" />
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}

View File

@ -1,143 +0,0 @@
import { useEffect, useState } from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import { ArrowLeft, BookOpen, ChevronRight } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg"
import { Badge } from "../../components/ui/badge"
import { getCoursesBySubCategoryId, getSubCategoriesByCategoryId } from "../../api/courses.api"
import type { CategorySubCategoryListItem, SubCategoryCourseListItem } from "../../types/course.types"
import { cn } from "../../lib/utils"
export function SubCategoryCoursesPage() {
const { categoryId, subCategoryId } = useParams<{
categoryId: string
subCategoryId: string
}>()
const navigate = useNavigate()
const [subCategory, setSubCategory] = useState<CategorySubCategoryListItem | null>(null)
const [courses, setCourses] = useState<SubCategoryCourseListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const run = async () => {
if (!categoryId || !subCategoryId) return
const cid = Number(categoryId)
const sid = Number(subCategoryId)
if (!Number.isFinite(cid) || !Number.isFinite(sid)) {
setError("Invalid route parameters")
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const [subRes, coursesRes] = await Promise.all([
getSubCategoriesByCategoryId(cid),
getCoursesBySubCategoryId(sid),
])
const list = subRes.data?.data?.sub_categories ?? []
const found = Array.isArray(list) ? list.find((s) => s.id === sid) : undefined
setSubCategory(found ?? null)
const raw = coursesRes.data?.data?.courses
setCourses(Array.isArray(raw) ? raw : [])
} catch (e) {
console.error(e)
setError("Failed to load courses for this sub-category")
setCourses([])
} finally {
setLoading(false)
}
}
void run()
}, [categoryId, subCategoryId])
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
<img src={alertSrc} alt="" className="h-10 w-10 shrink-0" />
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
</div>
)
}
const label = subCategory?.name ?? "Sub-category"
return (
<div className="space-y-6">
<div className="rounded-2xl border border-grayScale-100 bg-white px-5 py-4 shadow-sm sm:px-6 sm:py-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3.5">
<Link
to={`/content/category/${categoryId}/courses`}
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div>
<p className="text-xs font-medium text-grayScale-400">Sub-category</p>
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">{label}</h1>
<p className="mt-0.5 text-sm text-grayScale-400">
{courses.length} course{courses.length !== 1 ? "s" : ""} open a course to manage sub-modules
</p>
</div>
</div>
</div>
</div>
{courses.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">No courses in this sub-category yet</p>
<p className="mt-1 text-sm text-grayScale-400">Add a course from your authoring flow or API.</p>
</div>
) : (
<div className="space-y-3">
{courses.map((c) => (
<button
key={c.id}
type="button"
onClick={() =>
navigate(`/content/category/${categoryId}/courses/${c.id}/sub-modules`)
}
className={cn(
"flex w-full items-center justify-between gap-4 rounded-xl border border-grayScale-200 bg-white px-4 py-4 text-left shadow-sm transition-all",
"hover:border-brand-200 hover:bg-brand-50/40 hover:shadow-md",
)}
>
<div className="flex min-w-0 items-center gap-3">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400">
<BookOpen className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="font-semibold text-grayScale-800">{c.title}</p>
{c.description?.trim() ? (
<p className="mt-0.5 line-clamp-2 text-sm text-grayScale-500">{c.description}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<Badge variant={c.is_active ? "success" : "secondary"} className="text-[11px]">
{c.is_active ? "Active" : "Inactive"}
</Badge>
<ChevronRight className="h-5 w-5 text-grayScale-300" />
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -103,10 +103,9 @@ export function SubModuleContentPage() {
try {
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
const list = subCoursesRes.data?.data?.sub_courses
const foundSubCourse = Array.isArray(list)
? list.find((sc) => sc.id === Number(subModuleId))
: undefined
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
(sc) => sc.id === Number(subModuleId)
)
setSubCourse(foundSubCourse ?? null)
} catch (err) {
console.error("Failed to fetch course data:", err)
@ -124,9 +123,7 @@ export function SubModuleContentPage() {
setPracticesLoading(true)
try {
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
const raw = res.data?.data
const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
setPractices(Array.isArray(list) ? list : [])
setPractices(res.data.data ?? [])
} catch (err) {
console.error("Failed to fetch practices:", err)
} finally {
@ -139,8 +136,7 @@ export function SubModuleContentPage() {
setVideosLoading(true)
try {
const res = await getVideosBySubModule(Number(subModuleId))
const vids = res.data?.data?.videos ?? []
setVideos(Array.isArray(vids) ? vids : [])
setVideos(res.data.data.videos ?? [])
} catch (err) {
console.error("Failed to fetch videos:", err)
} finally {
@ -158,7 +154,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 {
@ -409,8 +405,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 ?? null)
setPreviewIframe(res.data.data.iframe)
setPreviewVideo(res.data.data.video)
} catch {
setPreviewIframe("")
} finally {
@ -418,7 +414,7 @@ export function SubModuleContentPage() {
}
}
const filteredPractices = (Array.isArray(practices) ? practices : []).filter((practice) => {
const filteredPractices = practices.filter((practice) => {
if (statusFilter === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED"
if (statusFilter === "draft") return practice.status === "DRAFT"
@ -444,19 +440,6 @@ export function SubModuleContentPage() {
)
}
if (!subCourse) {
return (
<div className="flex flex-col items-center justify-center py-20">
<img src={alertSrc} alt="" className="h-12 w-12" />
<p className="mt-3 text-sm font-medium text-grayScale-600">Sub-module not found</p>
<p className="mt-1 text-sm text-grayScale-400">It may have been removed or the link is invalid.</p>
<Button className="mt-6" variant="outline" asChild>
<Link to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`}>Back to sub-modules</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Back Button */}
@ -607,7 +590,7 @@ export function SubModuleContentPage() {
<div className="flex items-center gap-3 text-xs text-grayScale-400">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
<span>{String(practice.owner_type ?? "SUB_MODULE").replace(/_/g, " ")}</span>
<span>{practice.owner_type.replace("_", " ")}</span>
</div>
{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>
@ -616,13 +599,11 @@ 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">
{practice.created_at
? new Date(practice.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: "—"}
{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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { useEffect, useState, type FormEvent } from "react";
import { X } from "lucide-react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
@ -9,137 +9,51 @@ import {
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";
import { Select } from "../../../components/ui/select";
import uploadIcon from "../../../assets/icons/upload.png";
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);
}
};
export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
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>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl gap-0 border-none p-0 overflow-hidden rounded-[16px] shadow-2xl">
<DialogHeader className="p-8 pb-4 relative">
<DialogTitle className="text-2xl font-bold text-grayScale-700">
Add New Module
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a module to organize videos and practices.
</DialogDescription>
</DialogHeader>
<div className="relative">
{/* Gradient Divider */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-100" />
</div>
<div className="relative flex justify-center">
<div
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>
className="h-[0.5px] w-full opacity-20"
style={{ background: "gray" }}
/>
</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)}
>
<form className="space-y-6 p-8 pt-4">
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Module title
Module Title
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Greetings & Introductions"
placeholder="e.g. Daily Introductions"
className="h-12 rounded-xl"
disabled={submitting}
required
/>
</div>
@ -147,40 +61,63 @@ export function AddModuleModal({
<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}
<Input
placeholder="Short description of this module"
className="h-12 rounded-xl"
/>
</div>
<ModuleIconUploadField
value={icon}
onChange={setIcon}
disabled={submitting}
onUploadBusyChange={setIconUploadBusy}
/>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Module Order
</label>
<Select className="h-12 rounded-xl">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Icon
</label>
<div className="relative group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-sm">
<span className="font-bold text-[#9E2891]">
Click to upload
</span>{" "}
<span className="text-grayScale-500">or drag and drop</span>
</p>
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
JPG, PNG (MAX 1 MB)
</p>
</div>
</div>
</div>
<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}
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600 text-white shadow-lg shadow-brand-500/20"
>
{submitting ? "Creating…" : "Create module"}
Create Module
</Button>
</div>
</form>

View File

@ -1,539 +0,0 @@
import React, { useState } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from "@dnd-kit/core";
import type {
DragEndEvent,
DragStartEvent,
UniqueIdentifier,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
GripVertical,
ChevronDown,
ChevronRight,
LayoutGrid,
BookOpen,
Layers,
PlayCircle,
RotateCcw,
Edit2,
Trash2,
Image as ImageIcon,
} from "lucide-react";
import { cn } from "../../../lib/utils";
// --- Types ---
export type ItemType = "program" | "course" | "module" | "lesson";
export interface BaseItem {
id: string;
name: string;
thumbnail?: string;
}
export interface Program extends BaseItem {}
export interface Course extends BaseItem {
programId: string;
}
export interface Module extends BaseItem {
courseId: string;
}
export interface Lesson extends BaseItem {
moduleId: string;
}
// --- Mock Data ---
const initialPrograms: Program[] = [
{
id: "p1",
name: "Web Development Masterclass",
thumbnail:
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=100&h=100&fit=crop",
},
{
id: "p2",
name: "Mobile App Development",
thumbnail:
"https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=100&h=100&fit=crop",
},
{
id: "p3",
name: "UI/UX Design Fundamentals",
thumbnail:
"https://images.unsplash.com/photo-1586717791821-3f44a563eb4c?w=100&h=100&fit=crop",
},
];
const initialCourses: Course[] = [
{ id: "c1", name: "React for Beginners", programId: "p1" },
{ id: "c2", name: "Advanced Node.js", programId: "p1" },
{ id: "c3", name: "Swift UI Intro", programId: "p2" },
];
const initialModules: Module[] = [
{ id: "m1", name: "Introduction to Hooks", courseId: "c1" },
{ id: "m2", name: "State Management", courseId: "c1" },
{ id: "m3", name: "Backend Architecture", courseId: "c2" },
];
const initialLessons: Lesson[] = [
{ id: "l1", name: "What is useState?", moduleId: "m1" },
{ id: "l2", name: "useEffect deep dive", moduleId: "m1" },
{ id: "l3", name: "Redux Setup", moduleId: "m2" },
];
// --- Components ---
interface SortableItemProps {
id: string;
name: string;
icon: React.ReactNode;
thumbnail?: string;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
}
function SortableItem({
id,
name,
icon,
thumbnail,
onEdit,
onDelete,
}: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center justify-between px-4 py-3 border border-grayScale-200 rounded-[6px] mb-2 bg-white transition-all duration-200 group/item",
isDragging && "opacity-50 border-dashed z-50 shadow-sm",
!isDragging && "hover:border-brand-200 hover:shadow-sm",
)}
>
<div className="flex items-center gap-4">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 text-grayScale-300 hover:text-brand-500 transition-colors"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
{/* Thumbnail/Icon Container */}
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
{thumbnail ? (
<img
src={thumbnail}
alt={name}
className="h-full w-full object-cover"
/>
) : (
<div className="text-grayScale-400 group-hover/item:text-brand-500 transition-colors">
{icon}
</div>
)}
</div>
<div className="flex flex-col">
<span className="text-[14px] font-bold text-grayScale-800 leading-tight">
{name}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity">
<button
onClick={() => onEdit?.(id)}
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
title="Edit"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onDelete?.(id)}
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
interface DraggableListProps {
items: BaseItem[];
onReorder: (activeId: string, overId: string) => void;
icon: React.ReactNode;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
}
function DraggableList({
items,
onReorder,
icon,
onEdit,
onDelete,
}: DraggableListProps) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) =>
setActiveId(event.active.id);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
onReorder(active.id as string, over.id as string);
}
setActiveId(null);
};
const activeItem = items.find((i) => i.id === activeId);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1">
{items.map((item) => (
<SortableItem
key={item.id}
id={item.id}
name={item.name}
thumbnail={item.thumbnail}
icon={icon}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeItem ? (
<div className="flex items-center justify-between px-4 py-3 bg-white border border-brand-300 shadow-lg rounded-[6px] opacity-90 cursor-grabbing">
<div className="flex items-center gap-4">
<div className="p-1 ">
<GripVertical className="h-4 w-4" />
</div>
<div className="flex items-center gap-3">
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
{activeItem.thumbnail ? (
<img
src={activeItem.thumbnail}
alt={activeItem.name}
className="h-full w-full object-cover"
/>
) : (
<div className="text-brand-500">{icon}</div>
)}
</div>
<span className="text-[14px] font-bold text-grayScale-800">
{activeItem.name}
</span>
</div>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
interface SectionProps {
title: string;
icon: React.ReactNode;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function HierarchySection({
title,
icon,
isOpen,
onToggle,
children,
}: SectionProps) {
return (
<div className="border border-grayScale-100 rounded-xl mb-3 overflow-hidden transition-all duration-300 bg-white">
<button
onClick={onToggle}
className={cn(
"w-full flex items-center justify-between px-5 py-4 transition-colors",
isOpen ? "bg-grayScale-50" : "hover:bg-grayScale-25",
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"p-2 rounded-lg transition-colors",
isOpen
? "bg-brand-300 text-white"
: "bg-grayScale-50 text-grayScale-500",
)}
>
{icon}
</div>
<span
className={cn(
"text-[15px] font-bold",
isOpen ? "text-grayScale-900" : "text-grayScale-700",
)}
>
{title}
</span>
</div>
{isOpen ? (
<ChevronDown className="h-5 w-5 text-grayScale-400" />
) : (
<ChevronRight className="h-5 w-5 text-grayScale-400" />
)}
</button>
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isOpen ? "max-h-[1000px] opacity-100 p-5 pt-0" : "max-h-0 opacity-0",
)}
>
<div className="pt-4 border-t border-grayScale-200">{children}</div>
</div>
</div>
);
}
export function ContentHierarchyList() {
const [programs, setPrograms] = useState<Program[]>(initialPrograms);
const [courses, setCourses] = useState<Course[]>(initialCourses);
const [modules, setModules] = useState<Module[]>(initialModules);
const [lessons, setLessons] = useState<Lesson[]>(initialLessons);
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
program: true,
});
const toggleSection = (id: string) => {
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
};
const reorder = <T extends BaseItem>(
list: T[],
setList: React.Dispatch<React.SetStateAction<T[]>>,
activeId: string,
overId: string,
) => {
const oldIndex = list.findIndex((i) => i.id === activeId);
const newIndex = list.findIndex((i) => i.id === overId);
if (oldIndex !== -1 && newIndex !== -1) {
setList(arrayMove(list, oldIndex, newIndex));
}
};
const handleEdit = (type: ItemType, id: string) => {
console.log(`Edit ${type}: ${id}`);
// Logic for opening edit modal would go here
};
const handleDelete = (type: ItemType, id: string) => {
if (!window.confirm(`Are you sure you want to delete this ${type}?`))
return;
switch (type) {
case "program":
setPrograms((prev) => prev.filter((p) => p.id !== id));
break;
case "course":
setCourses((prev) => prev.filter((c) => c.id !== id));
break;
case "module":
setModules((prev) => prev.filter((m) => m.id !== id));
break;
case "lesson":
setLessons((prev) => prev.filter((l) => l.id !== id));
break;
}
};
const handleReset = () => {
setPrograms(initialPrograms);
setCourses(initialCourses);
setModules(initialModules);
setLessons(initialLessons);
};
return (
<div className="bg-[#ffffff] rounded-2xl p-6 border border-grayScale-100 mb-8 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">
Content Hierarchy
</h3>
<p className="text-[12px] text-grayScale-500 mt-1">
Manage the ordering and structure of your educational content
</p>
</div>
<button
onClick={handleReset}
className="text-[13px] font-bold text-brand-300 hover:text-brand-400 transition-colors flex items-center gap-2 group"
>
<RotateCcw className="h-4 w-4 transition-transform group-hover:rotate-[-45deg]" />
Reset All
</button>
</div>
<div className="space-y-4">
{/* Program Section */}
<HierarchySection
title="Programs"
icon={<LayoutGrid className="h-5 w-5" />}
isOpen={openSections.program}
onToggle={() => toggleSection("program")}
>
<DraggableList
items={programs}
onReorder={(active, over) =>
reorder(programs, setPrograms, active, over)
}
icon={<LayoutGrid className="h-4 w-4" />}
onEdit={(id) => handleEdit("program", id)}
onDelete={(id) => handleDelete("program", id)}
/>
</HierarchySection>
{/* Course Section */}
<HierarchySection
title="Courses"
icon={<BookOpen className="h-5 w-5" />}
isOpen={openSections.course}
onToggle={() => toggleSection("course")}
>
{programs.map((program) => {
const programCourses = courses.filter(
(c) => c.programId === program.id,
);
if (programCourses.length === 0) return null;
return (
<div key={program.id} className="mb-4 last:mb-0">
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
{program.name}
</h4>
<DraggableList
items={programCourses}
onReorder={(active, over) =>
reorder(courses, setCourses, active, over)
}
icon={<BookOpen className="h-4 w-4" />}
onEdit={(id) => handleEdit("course", id)}
onDelete={(id) => handleDelete("course", id)}
/>
</div>
);
})}
</HierarchySection>
{/* Module Section */}
<HierarchySection
title="Modules"
icon={<Layers className="h-5 w-5" />}
isOpen={openSections.module}
onToggle={() => toggleSection("module")}
>
{courses.map((course) => {
const courseModules = modules.filter(
(m) => m.courseId === course.id,
);
if (courseModules.length === 0) return null;
return (
<div key={course.id} className="mb-4 last:mb-0">
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
{course.name}
</h4>
<DraggableList
items={courseModules}
onReorder={(active, over) =>
reorder(modules, setModules, active, over)
}
icon={<Layers className="h-4 w-4" />}
onEdit={(id) => handleEdit("module", id)}
onDelete={(id) => handleDelete("module", id)}
/>
</div>
);
})}
</HierarchySection>
{/* Lesson Section */}
<HierarchySection
title="Lessons"
icon={<PlayCircle className="h-5 w-5" />}
isOpen={openSections.lesson}
onToggle={() => toggleSection("lesson")}
>
{modules.map((module) => {
const moduleLessons = lessons.filter(
(l) => l.moduleId === module.id,
);
if (moduleLessons.length === 0) return null;
return (
<div key={module.id} className="mb-4 last:mb-0">
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
{module.name}
</h4>
<DraggableList
items={moduleLessons}
onReorder={(active, over) =>
reorder(lessons, setLessons, active, over)
}
icon={<PlayCircle className="h-4 w-4" />}
onEdit={(id) => handleEdit("lesson", id)}
onDelete={(id) => handleDelete("lesson", id)}
/>
</div>
);
})}
</HierarchySection>
</div>
</div>
);
}

View File

@ -1,481 +0,0 @@
import { useCallback, useEffect, useState } from "react"
import { Check, ChevronLeft, ChevronRight, ListOrdered, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"
import { Input } from "../../../components/ui/input"
import { Textarea } from "../../../components/ui/textarea"
import {
addQuestionToSet,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../../../api/courses.api"
import type { CreateQuestionRequest, PracticeParentKind } from "../../../types/course.types"
import { cn } from "../../../lib/utils"
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
export type CreatePracticeWizardParent = {
kind: PracticeParentKind
id: number
} | null
const STEPS = [
{ n: 1, label: "Question set" },
{ n: 2, label: "Questions" },
{ n: 3, label: "Attach" },
{ n: 4, label: "Practice" },
] as const
type QuestionDraft = {
question_text: string
voice_prompt: string
sample_answer_voice_prompt: string
audio_correct_answer_text: string
}
const emptyQuestion = (): QuestionDraft => ({
question_text: "",
voice_prompt: "",
sample_answer_voice_prompt: "",
audio_correct_answer_text: "",
})
type Props = {
parent: CreatePracticeWizardParent
onCreated?: () => void
}
export function CreatePracticeWizard({ parent, onCreated }: Props) {
const [step, setStep] = useState(1)
const [saving, setSaving] = useState(false)
const [setTitle, setSetTitle] = useState("")
const [questionSetId, setQuestionSetId] = useState<number | null>(null)
const [questionRows, setQuestionRows] = useState<QuestionDraft[]>([emptyQuestion()])
const [createdQuestionIds, setCreatedQuestionIds] = useState<number[]>([])
const [practiceTitle, setPracticeTitle] = useState("")
const [storyDescription, setStoryDescription] = useState("")
const [storyImage, setStoryImage] = useState("")
const [quickTips, setQuickTips] = useState("")
const canUseWizard = parent != null
useEffect(() => {
if (step === 4 && setTitle.trim() && !practiceTitle.trim()) {
setPracticeTitle(setTitle.trim())
}
}, [step, setTitle, practiceTitle])
const resetAll = useCallback(() => {
setStep(1)
setSetTitle("")
setQuestionSetId(null)
setQuestionRows([emptyQuestion()])
setCreatedQuestionIds([])
setPracticeTitle("")
setStoryDescription("")
setStoryImage("")
setQuickTips("")
}, [])
const handleStep1 = async () => {
if (!setTitle.trim()) {
toast.error("Enter a title for the question set")
return
}
setSaving(true)
try {
const res = await createQuestionSet({
title: setTitle.trim(),
set_type: "PRACTICE",
})
const id = res.data?.data?.id
if (id == null) {
throw new Error("No question set id in response")
}
setQuestionSetId(id)
toast.success("Question set created")
setStep(2)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to create question set")
} finally {
setSaving(false)
}
}
const handleStep2 = async () => {
for (let i = 0; i < questionRows.length; i++) {
const r = questionRows[i]
if (!r.question_text.trim()) {
toast.error(`Question ${i + 1}: enter question text`)
return
}
if (!r.voice_prompt.trim() || !r.sample_answer_voice_prompt.trim()) {
toast.error(`Question ${i + 1}: enter voice prompt URLs`)
return
}
if (!r.audio_correct_answer_text.trim()) {
toast.error(`Question ${i + 1}: enter the correct answer text`)
return
}
}
if (questionSetId == null) return
setSaving(true)
try {
const ids: number[] = []
for (const r of questionRows) {
const body: CreateQuestionRequest = {
question_text: r.question_text.trim(),
question_type: "AUDIO",
voice_prompt: r.voice_prompt.trim(),
sample_answer_voice_prompt: r.sample_answer_voice_prompt.trim(),
audio_correct_answer_text: r.audio_correct_answer_text.trim(),
}
const res = await createQuestion(body)
const qid = res.data?.data?.id
if (qid == null) {
throw new Error("A question was created but no id was returned")
}
ids.push(qid)
}
setCreatedQuestionIds(ids)
toast.success(`Created ${ids.length} question(s)`)
setStep(3)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to create questions")
} finally {
setSaving(false)
}
}
const handleStep3 = async () => {
if (questionSetId == null || createdQuestionIds.length === 0) return
setSaving(true)
try {
for (let i = 0; i < createdQuestionIds.length; i++) {
await addQuestionToSet(questionSetId, {
question_id: createdQuestionIds[i],
display_order: i + 1,
})
}
toast.success("Questions linked to the set")
setStep(4)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to attach questions")
} finally {
setSaving(false)
}
}
const handleStep4 = async () => {
if (!parent || questionSetId == null) return
if (!practiceTitle.trim() || !storyDescription.trim() || !storyImage.trim()) {
toast.error("Title, story description, and story image are required")
return
}
setSaving(true)
try {
await createParentLinkedPractice({
parent_kind: parent.kind,
parent_id: parent.id,
title: practiceTitle.trim(),
story_description: storyDescription.trim(),
story_image: storyImage.trim(),
question_set_id: questionSetId,
quick_tips: quickTips.trim(),
})
toast.success("Practice created successfully")
resetAll()
onCreated?.()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } }; message?: string }
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
} finally {
setSaving(false)
}
}
return (
<Card className="border-brand-200/60 shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-800">Create a new practice</CardTitle>
<p className="text-sm font-normal text-grayScale-500">
Four steps: create a question set, add audio questions, attach them, then set the practice
story. Select the course, module, or lesson above first.
</p>
<ol className="mt-4 flex flex-wrap gap-2">
{STEPS.map((s) => {
const done = step > s.n
const active = step === s.n
return (
<li
key={s.n}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider",
done && "border-mint-500/40 bg-mint-50 text-mint-800",
active && !done && "border-brand-500 bg-brand-500 text-white",
!active && !done && "border-grayScale-200 bg-white text-grayScale-500",
)}
>
{done ? <Check className="h-3.5 w-3.5" /> : <span className="font-mono tabular-nums">{s.n}</span>}
{s.label}
</li>
)
})}
</ol>
</CardHeader>
<CardContent className="pt-5">
{!canUseWizard && (
<p className="rounded-xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
Choose a program, course, and the target (course / module / lesson) in the &quot;Look up
practice&quot; 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 &amp; 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 &amp; 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/&#123;id&#125;/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>
)
}

View File

@ -1,215 +0,0 @@
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { CloudUpload } from "lucide-react";
import { toast } from "sonner";
import { Input } from "../../../components/ui/input";
import { cn } from "../../../lib/utils";
import { uploadImageFile, uploadVideoFile } from "../../../api/files.api";
const MAX_THUMB_BYTES = 5 * 1024 * 1024;
const MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
const THUMB_TYPES = new Set(["image/jpeg", "image/png"]);
const VIDEO_TYPES_PREFIX = "video/";
function isAllowedThumb(file: File): boolean {
if (THUMB_TYPES.has(file.type)) return true;
const n = file.name.toLowerCase();
return /\.(jpe?g|png)$/.test(n);
}
function isAllowedVideoFile(file: File): boolean {
if (file.type.startsWith(VIDEO_TYPES_PREFIX)) return true;
const n = file.name.toLowerCase();
return /\.(mp4|webm|mov|m4v|mkv)$/.test(n);
}
export type LessonMediaUploadKind = "thumbnail" | "video";
export interface LessonMediaUploadFieldProps {
kind: LessonMediaUploadKind;
value: string;
onChange: (url: string) => void;
disabled?: boolean;
onUploadBusyChange?: (busy: boolean) => void;
className?: string;
}
export function LessonMediaUploadField({
kind,
value,
onChange,
disabled = false,
onUploadBusyChange,
className,
}: LessonMediaUploadFieldProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const setBusy = useCallback(
(next: boolean) => {
setUploading(next);
onUploadBusyChange?.(next);
},
[onUploadBusyChange],
);
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return;
if (kind === "thumbnail") {
if (!isAllowedThumb(file)) {
toast.error("Please use a JPG or PNG image.");
return;
}
if (file.size > MAX_THUMB_BYTES) {
toast.error("Image is too large", {
description: "Maximum size is 5 MB.",
});
return;
}
setBusy(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
onChange(url);
toast.success("Thumbnail uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(msg);
} finally {
setBusy(false);
}
return;
}
if (!isAllowedVideoFile(file)) {
toast.error("Please use a video file (e.g. MP4, WebM, MOV).");
return;
}
if (file.size > MAX_VIDEO_BYTES) {
toast.error("Video is too large", {
description: "Maximum size is 2 GB.",
});
return;
}
setBusy(true);
try {
const res = await uploadVideoFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Upload did not return a file URL");
onChange(url);
toast.success("Video uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(msg);
} finally {
setBusy(false);
}
},
[disabled, uploading, kind, onChange, setBusy],
);
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void processFile(file);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled && !uploading) setDragActive(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (disabled || uploading) return;
const file = e.dataTransfer.files?.[0];
if (file) void processFile(file);
};
const zoneDisabled = disabled || uploading;
const isThumb = kind === "thumbnail";
const label = isThumb ? "Thumbnail" : "Video";
const hint = isThumb
? "JPG, PNG (MAX 5 MB)"
: "MP4, MOV, WebM (MAX 2 GB)";
return (
<div className={cn("space-y-3", className)}>
<label className="text-sm font-medium text-grayScale-700">
{label}
</label>
<input
ref={fileInputRef}
type="file"
accept={
isThumb
? "image/jpeg,image/png,.jpg,.jpeg,.png"
: "video/*,.mp4,.webm,.mov,.m4v,.mkv"
}
className="sr-only"
onChange={handleFileInputChange}
disabled={zoneDisabled}
/>
<button
type="button"
disabled={zoneDisabled}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
dragActive && "border-[#9E2891] bg-[#9E289108]",
zoneDisabled && "cursor-not-allowed opacity-60",
)}
>
{uploading ? (
<p className="text-sm font-medium text-grayScale-600">Uploading</p>
) : (
<>
<CloudUpload
className="mb-4 h-10 w-10 text-[#9E2891]"
strokeWidth={1.5}
aria-hidden
/>
<p className="text-sm">
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
{hint}
</p>
</>
)}
</button>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://…"
className="h-12 rounded-xl"
disabled={disabled || uploading}
autoComplete="off"
/>
</div>
);
}

View File

@ -1,166 +0,0 @@
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { CloudUpload } from "lucide-react";
import { toast } from "sonner";
import { Input } from "../../../components/ui/input";
import { cn } from "../../../lib/utils";
import { uploadImageFile } from "../../../api/files.api";
const MAX_ICON_BYTES = 5 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]);
function isAllowedImageFile(file: File): boolean {
if (ALLOWED_IMAGE_TYPES.has(file.type)) return true;
const name = file.name.toLowerCase();
return /\.(jpe?g|png)$/.test(name);
}
export interface ModuleIconUploadFieldProps {
value: string;
onChange: (url: string) => void;
disabled?: boolean;
/** Notifies parent so dialogs can block closing while an upload is in flight. */
onUploadBusyChange?: (busy: boolean) => void;
className?: string;
}
export function ModuleIconUploadField({
value,
onChange,
disabled = false,
onUploadBusyChange,
className,
}: ModuleIconUploadFieldProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const setBusy = useCallback(
(next: boolean) => {
setUploading(next);
onUploadBusyChange?.(next);
},
[onUploadBusyChange],
);
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return;
if (!isAllowedImageFile(file)) {
toast.error("Please use a JPG or PNG image.");
return;
}
if (file.size > MAX_ICON_BYTES) {
toast.error("Image is too large", {
description: "Maximum size is 5 MB.",
});
return;
}
setBusy(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) {
throw new Error("Upload did not return a file URL");
}
onChange(url);
toast.success("Icon uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload icon";
toast.error(msg);
} finally {
setBusy(false);
}
},
[disabled, uploading, onChange, setBusy],
);
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void processFile(file);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled && !uploading) setDragActive(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (disabled || uploading) return;
const file = e.dataTransfer.files?.[0];
if (file) void processFile(file);
};
const zoneDisabled = disabled || uploading;
const showSpinner = uploading;
return (
<div className={cn("space-y-3", className)}>
<label className="text-[15px] font-medium text-grayScale-700 md:text-sm">
Icon
</label>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,.jpg,.jpeg,.png"
className="sr-only"
onChange={handleFileInputChange}
disabled={zoneDisabled}
/>
<button
type="button"
disabled={zoneDisabled}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
dragActive && "border-[#9E2891] bg-[#9E289108]",
zoneDisabled && "cursor-not-allowed opacity-60",
)}
>
{showSpinner ? (
<p className="text-sm font-medium text-grayScale-600">Uploading</p>
) : (
<>
<CloudUpload
className="mb-4 h-10 w-10 text-[#9E2891]"
strokeWidth={1.5}
aria-hidden
/>
<p className="text-sm">
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
JPG, PNG (MAX 5 MB)
</p>
</>
)}
</button>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://…"
className="h-12 rounded-xl"
disabled={disabled || uploading}
autoComplete="off"
/>
</div>
);
}

View File

@ -1,66 +0,0 @@
import { useState } from "react";
import { cn } from "../../../lib/utils";
import {
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
} from "../../../lib/videoPreview";
type Props = {
src: string;
maxSeconds?: number;
};
/**
* Stops direct file playback after the first N seconds (admin short preview).
*/
export function PreviewLimitedFileVideo({
src,
maxSeconds = DEFAULT_PREVIEW_MAX_SECONDS,
}: Props) {
const [capped, setCapped] = useState(false);
const previewLengthLabel = formatPreviewLength(maxSeconds);
const onTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const el = e.currentTarget;
if (el.currentTime >= maxSeconds) {
el.pause();
if (el.currentTime > maxSeconds) {
el.currentTime = maxSeconds;
}
setCapped(true);
} else {
setCapped(false);
}
};
const onSeeking = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const el = e.currentTarget;
if (el.currentTime > maxSeconds) {
el.currentTime = maxSeconds;
}
};
return (
<div className="relative">
<video
controls
playsInline
className="aspect-video w-full object-contain"
src={src}
onTimeUpdate={onTimeUpdate}
onSeeking={onSeeking}
onPlay={() => setCapped(false)}
/>
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold",
capped ? "text-amber-200" : "text-white/95",
)}
>
{capped
? `Preview stopped at ${previewLengthLabel} · rewind to rewatch the clip`
: `Short clip · playback stops at ${previewLengthLabel}`}
</div>
</div>
);
}

View File

@ -1,43 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { MoreVertical, Edit2, Play, Pencil, Trash2 } from "lucide-react";
import { MoreVertical, Edit2, Play } 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;
id: string;
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;
duration: string;
status: "Draft" | "Published";
thumbnailGradient: string;
onEdit?: () => void;
onDelete?: () => void;
onPublish?: () => void;
}
@ -45,330 +16,84 @@ export function VideoCard({
title,
duration,
status,
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
thumbnailUrl,
videoUrl,
thumbnailGradient,
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",
)}
>
<div className="group bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col">
{/* Thumbnail */}
<div
className={cn(
"relative h-44 w-full overflow-hidden",
useGradient && "bg-gradient-to-br",
useGradient && thumbnailGradient,
!useGradient && "bg-grayScale-100",
"relative h-44 w-full bg-gradient-to-br",
thumbnailGradient,
)}
>
{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 className="absolute bottom-3 right-3 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
{duration}
</div>
{/* Play Overlay */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
<Play className="h-6 w-6 text-white fill-current" />
</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>
</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 cant 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 cant 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",
)}
>
<div className="flex items-center 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",
status === "Published"
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
)}
>
<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]",
"h-1.5 w-1.5 rounded-full",
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
)}
>
<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}
/>
{status}
</div>
{/* Menu */}
<button className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400">
<MoreVertical className="h-5 w-5" />
</button>
</div>
<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}
{/* Actions */}
<div className="pt-2 space-y-3 mt-auto">
<Button
variant="outline"
onClick={onEdit}
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
>
<Edit2 className="h-4 w-4" />
Edit
</Button>
<Button
disabled={status === "Published"}
onClick={onPublish}
className={cn(
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
status === "Published"
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
)}
>
{status === "Published" ? "Published" : "Publish"}
</Button>
</div>
</div>
</div>
);

View File

@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Play, Pause, X } from "lucide-react";
import { cn } from "../../../../lib/utils";
import { resolveDisplayMediaUrl } from "../../../../lib/mediaUrl";
interface VoicePromptProps {
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
@ -22,34 +21,13 @@ export function VoicePrompt({
const [bars, setBars] = useState<number[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); // 01
const [playableSrc, setPlayableSrc] = useState("");
const audioRef = useRef<HTMLAudioElement | null>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const raw = src?.trim() || "";
if (!raw) {
setPlayableSrc("");
return;
}
try {
const resolved = await resolveDisplayMediaUrl(raw);
if (!cancelled) setPlayableSrc(resolved || raw);
} catch {
if (!cancelled) setPlayableSrc(raw);
}
})();
return () => {
cancelled = true;
};
}, [src]);
// ─── Decode audio and build waveform bars ───────────────────────────────────
useEffect(() => {
if (!playableSrc) {
if (!src) {
// No real audio — generate plausible static bars
setBars(generateFakeBars());
return;
@ -58,7 +36,7 @@ export function VoicePrompt({
let cancelled = false;
const audioCtx = new AudioContext();
fetch(playableSrc)
fetch(src)
.then((r) => r.arrayBuffer())
.then((buf) => audioCtx.decodeAudioData(buf))
.then((decoded) => {
@ -84,15 +62,7 @@ export function VoicePrompt({
return () => {
cancelled = true;
};
}, [playableSrc]);
useEffect(() => {
audioRef.current?.pause();
audioRef.current = null;
setIsPlaying(false);
setProgress(0);
stopProgressLoop();
}, [playableSrc]);
}, [src]);
// ─── Sync progress while playing ────────────────────────────────────────────
const startProgressLoop = () => {
@ -114,10 +84,10 @@ export function VoicePrompt({
// ─── Play / Pause ────────────────────────────────────────────────────────────
const handlePlayPause = () => {
if (!playableSrc) return;
if (!src) return;
if (!audioRef.current) {
audioRef.current = new Audio(playableSrc);
audioRef.current = new Audio(src);
audioRef.current.onended = () => {
setIsPlaying(false);
setProgress(0);

View File

@ -1,167 +1,86 @@
import { useEffect, useMemo, useState } from "react";
import { Rocket, Edit2, Link2, Video } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { toast } from "sonner";
import type { AddLessonFormData } from "../../AddVideoFlow";
import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
getVideoPreview,
resolveThumbnailForPreview,
} from "../../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
Rocket,
Edit2,
Layout,
Volume2,
Settings,
Maximize2,
} from "lucide-react";
import { Button } from "../../../../components/ui/button";
interface ReviewPublishStepProps {
formData: AddLessonFormData;
formData: any;
prevStep: () => void;
onPublish: () => void;
publishing: boolean;
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return `${s.slice(0, max)}`;
setIsPublished: (val: boolean) => void;
}
export function ReviewPublishStep({
formData,
prevStep,
onPublish,
publishing,
setIsPublished,
}: ReviewPublishStepProps) {
const [thumbBroken, setThumbBroken] = useState(false);
const videoPreview = useMemo(
() => getVideoPreview(formData.videoUrl),
[formData.videoUrl],
);
const limitedEmbedSrc = useMemo(() => {
if (videoPreview.kind !== "iframe") return null;
return applyShortPreviewToEmbedUrl(
videoPreview.src,
videoPreview.label,
DEFAULT_PREVIEW_MAX_SECONDS,
);
}, [videoPreview]);
const previewLengthLabel = formatPreviewLength(DEFAULT_PREVIEW_MAX_SECONDS);
const thumbSrc = useMemo(
() => resolveThumbnailForPreview(formData.thumbnailUrl),
[formData.thumbnailUrl],
);
useEffect(() => {
setThumbBroken(false);
}, [thumbSrc]);
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
{/* 1. Video Preview Card */}
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
<div className="px-8 py-5 border-b border-grayScale-50 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between bg-white">
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
<h3 className="text-[17px] font-bold text-grayScale-900">
Media preview
Video Preview
</h3>
<p className="text-xs font-medium text-grayScale-500">
Video: short clip (first {previewLengthLabel} only)
</p>
<span className="bg-[#FAF5FF] text-brand-500 text-[10px] font-bold px-3 py-1.5 rounded-[6px] tracking-wider uppercase border border-brand-100/50">
PROCESSED
</span>
</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 className="p-10 flex items-center justify-center bg-[#F8FAFC]/30">
<div className="relative w-full max-w-4xl aspect-video rounded-[12px] overflow-hidden bg-black shadow-2xl group border-4 border-white">
{/* Mock Player Control Overlays */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-16 w-16 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30 cursor-pointer hover:scale-110 transition-transform">
<div className="w-0 h-0 border-t-[10px] border-t-transparent border-l-[18px] border-l-white border-b-[10px] border-b-transparent ml-1" />
</div>
</div>
{/* 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>
{/* Bottom Controls — Matching Image 1884 */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/95 via-black/40 to-transparent space-y-4">
{/* Row 1: Seeker and Timestamps */}
<div className="flex items-center gap-4 text-white">
<span className="text-[13px] font-medium opacity-90">0:00</span>
<div className="flex-1 h-1 bg-white/20 rounded-full relative cursor-pointer overflow-hidden group/seeker">
<div
className="absolute left-0 top-0 bottom-0 bg-brand-500 rounded-full"
style={{ width: "40%" }}
/>
</div>
) : (
<p className="text-grayScale-400 text-sm"></p>
)}
<span className="text-[13px] font-medium opacity-90">
12:30
</span>
</div>
{/* Row 2: Icons */}
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-6">
<Volume2 className="h-[22px] w-[22px] opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
<div className="h-5 w-6 border-2 border-white rounded-[3px] flex items-center justify-center text-[9px] font-bold opacity-90 cursor-pointer hover:opacity-100 transition-opacity">
CC
</div>
</div>
<div className="flex items-center gap-6">
<Settings className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
<Maximize2 className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
</div>
</div>
</div>
</div>
</div>
</div>
{/* 2. Content Details Card */}
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
<h3 className="text-[16px] font-bold text-grayScale-900">
Content details
Content Details
</h3>
<button
type="button"
onClick={prevStep}
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
>
@ -171,29 +90,70 @@ export function ReviewPublishStep({
</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>
{/* Metadata Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
TITLE
</span>
<p className="text-[15px] font-medium text-grayScale-900">
{formData.title || "Introduction to Past Tense"}
</p>
</div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
ASSIGNED MODULE
</span>
<div className="flex items-center gap-2">
<Layout className="h-4 w-4 text-grayScale-400" />
<p className="text-[14px] font-medium text-grayScale-700">
Grammar Basics - Level 1
</p>
</div>
</div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
TEACHER NAME
</span>
<p className="text-[15px] font-medium text-grayScale-600">
Abebe Kebede
</p>
</div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
FILE SIZE
</span>
<div className="flex items-baseline gap-1.5">
<span className="text-[15px] font-bold text-grayScale-900">
245 MB
</span>
<span className="text-[13px] text-grayScale-400 font-medium">
(1080p MP4)
</span>
</div>
</div>
</div>
{/* Description Section */}
<div className="space-y-3">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Description
DESCRIPTION
</span>
<div
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
dangerouslySetInnerHTML={{
__html:
formData.description || "<p class='text-grayScale-400'>—</p>",
formData.description ||
"This video covers the fundamental rules of forming the past tense in English, focusing on regular verbs ending in -ed. Suitable for beginners. Includes examples and common pitfalls.",
}}
/>
</div>
</div>
{/* Gradient Divider */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
@ -204,17 +164,18 @@ export function ReviewPublishStep({
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{ background: "gray" }}
style={{
background: "gray",
}}
/>
</div>
</div>
{/* 3. Normal Footer (Inside Card) */}
<div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={publishing}
className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
>
Back
@ -222,24 +183,17 @@ export function ReviewPublishStep({
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
disabled={publishing}
onClick={() =>
toast.info("Drafts are not supported yet. Use Create lesson.")
}
>
Save as draft
Save as Draft
</Button>
<Button
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"
onClick={() => setIsPublished(true)}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5"
>
<Rocket className="h-4 w-4" />
{publishing ? "Creating…" : "Create lesson"}
Publish Now
</Button>
</div>
</div>

View File

@ -1,36 +1,32 @@
import { useRef, useEffect } from "react";
import {
useRef,
useEffect,
type Dispatch,
type SetStateAction,
} from "react";
import { List, Link as LinkIcon, Lightbulb, ArrowRight } from "lucide-react";
import { toast } from "sonner";
Video,
List,
Link as LinkIcon,
Lightbulb,
ChevronRight,
ImageIcon,
ArrowRight,
} from "lucide-react";
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(/&nbsp;/g, " ").trim();
return t.length === 0;
}
import { Select } from "../../../../components/ui/select";
interface VideoDetailStepProps {
formData: AddLessonFormData;
setFormData: Dispatch<SetStateAction<AddLessonFormData>>;
onContinue: () => void;
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
}
export function VideoDetailStep({
formData,
setFormData,
onContinue,
nextStep,
}: VideoDetailStepProps) {
const editorRef = useRef<HTMLDivElement>(null);
const isInternalChange = useRef(false);
// Initialize editor content only once or when needed from outside
useEffect(() => {
if (editorRef.current && !isInternalChange.current) {
editorRef.current.innerHTML = formData.description || "";
@ -45,10 +41,8 @@ export function VideoDetailStep({
const syncState = () => {
if (editorRef.current) {
isInternalChange.current = true;
setFormData((prev) => ({
...prev,
description: editorRef.current!.innerHTML,
}));
setFormData({ ...formData, description: editorRef.current.innerHTML });
// Reset after a short delay to allow exterior updates if any (e.g., from step change)
setTimeout(() => {
isInternalChange.current = false;
}, 0);
@ -59,57 +53,50 @@ export function VideoDetailStep({
syncState();
};
const handleContinue = () => {
if (editorRef.current) {
setFormData((prev) => ({
...prev,
description: editorRef.current!.innerHTML,
}));
}
if (!formData.title.trim()) {
toast.error("Title is required");
return;
}
if (!formData.videoUrl.trim()) {
toast.error("Add a video URL or upload a video");
return;
}
if (!formData.thumbnailUrl.trim()) {
toast.error("Add a thumbnail or upload an image");
return;
}
const descHtml = editorRef.current?.innerHTML ?? formData.description;
if (isDescriptionEmpty(descHtml)) {
toast.error("Description is required");
return;
}
onContinue();
};
return (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20">
{/* Single Unified Card for Everything */}
<div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8">
<div className="space-y-3">
{/* 1. Upload Video Section */}
<div className="space-y-6">
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
Video
Upload 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 group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-[20px] border-2 border-dashed border-[#E2E8F0] bg-[#F8FAFC]/30 p-14 transition-all hover:border-brand-200 hover:bg-brand-50/5">
<div className="h-16 w-16 rounded-full bg-white shadow-sm flex items-center justify-center mb-6">
<div className="h-10 w-10 rounded-full bg-[#FAF5FF] flex items-center justify-center">
<div className="h-6 w-6 relative flex items-center justify-center">
<div className="absolute inset-0 bg-brand-500/10 rounded-full blur-sm" />
<Video className="h-5 w-5 text-brand-500 relative" />
</div>
</div>
</div>
<h4 className="text-[17px] text-grayScale-900 mb-2">
Drag and drop video files here
</h4>
<p className="text-grayScale-400 font-medium text-[13px] mb-8">
MP4, MOV, WebM. Max size 2GB.
</p>
<div className="flex items-center gap-4 w-full max-w-[200px] mb-8">
<div className="flex-1 h-[1px] bg-grayScale-200" />
<span className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest">
OR
</span>
<div className="flex-1 h-[1px] bg-grayScale-200" />
</div>
<Button
variant="outline"
className="h-11 px-8 rounded-xl border-grayScale-200 bg-white font-bold text-brand-500 hover:border-brand-500 hover:bg-brand-50 transition-all shadow-sm text-sm"
>
Browse Files
</Button>
</div>
</div>
</div>
{/* Gradient Divider */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
@ -120,57 +107,75 @@ export function VideoDetailStep({
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{ background: "gray" }}
style={{
background: "gray",
}}
/>
</div>
</div>
{/* 2. Form & Side Panel Grid */}
<div className="flex flex-col lg:flex-row gap-12 items-start">
{/* Left Column: Title, Order, Description */}
<div className="flex-1 w-full space-y-10">
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Lesson title
Video Title
</label>
<Input
placeholder="e.g. Introduction to Past Tense"
placeholder="e.g., Introduction to Past Tense Verbs"
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({ ...prev, title: e.target.value }))
setFormData({ ...formData, title: e.target.value })
}
/>
</div>
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Video Order
</label>
<Select
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 font-medium cursor-pointer focus:border-brand-500 shadow-sm"
value={formData.order}
onChange={(e) =>
setFormData({ ...formData, order: (e.target as any).value })
}
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</Select>
</div>
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Description
</label>
<div className="rounded-xl border border-grayScale-200 bg-white overflow-hidden flex flex-col min-h-[200px] shadow-sm focus-within:border-brand-200 transition-all">
{/* Toolbar */}
<div className="flex items-center gap-1 bg-[#F8FAFC]">
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
<button
type="button"
onClick={() => handleCommand("bold")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif font-bold text-[17px] pb-0.5 active:bg-grayScale-50"
>
B
</button>
<button
type="button"
onClick={() => handleCommand("italic")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif italic text-[17px] pr-0.5 active:bg-grayScale-50"
>
I
</button>
<button
type="button"
onClick={() => handleCommand("insertUnorderedList")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
>
<List className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => {
const url = prompt("Enter URL:");
if (url) handleCommand("createLink", url);
@ -183,9 +188,12 @@ export function VideoDetailStep({
</div>
<div className="relative p-6 flex-1">
{isDescriptionEmpty(formData.description) && (
{(!formData.description ||
formData.description === "<br>" ||
formData.description === "" ||
formData.description === "<div><br></div>") && (
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
What will students learn in this lesson?
Provide a brief summary of what the student will learn...
</div>
)}
<div
@ -199,44 +207,59 @@ export function VideoDetailStep({
</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 }))
}
/>
{/* Right Column: Thumbnail, Pro Tip */}
<div className="w-full lg:w-[320px] space-y-5">
{/* Thumbnail Section */}
<div className="space-y-4">
<div className="space-y-1 ml-1">
<h3 className="text-[14px] font-medium text-grayScale-900">
Thumbnail
</h3>
<p className="text-[12px] text-grayScale-400 font-medium leading-relaxed">
Upload your video thumbnail. 1280×720px recommended.
</p>
</div>
<div className="relative group cursor-pointer aspect-video">
<div className="h-full w-full flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-grayScale-200 bg-[#F8FAFC]/50 p-6 transition-all group-hover:border-brand-200">
<div className="h-10 w-10 flex items-center justify-center mb-3">
<ImageIcon className="h-7 w-7 text-grayScale-400" />
</div>
<p className="text-[13px] font-bold text-brand-400">
Click to upload
</p>
</div>
</div>
</div>
{/* Pro Tip Section */}
<div className="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
<Lightbulb
className="h-4 w-4 text-brand-50"
fill="#A855F7"
/>
<Lightbulb className="h-4 w-4 text-brand-50" fill="#A855F7" />
</div>
</div>
<div className="relative top-[-10px]">
<h3 className="text-[14px] font-bold text-grayScale-900">
Pro tip
Pro Tip
</h3>
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
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.
Short, descriptive titles work best. Include keywords like
"Grammar" or "Vocabulary" to help students find your content.
</p>
</div>
</div>
</div>
</div>
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-end">
{/* Footer (Inside Card Container) */}
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[14px] font-medium text-grayScale-600">
Last saved: Just now
</span>
</div>
<Button
type="button"
onClick={handleContinue}
onClick={nextStep}
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

View File

@ -95,13 +95,12 @@ function getStatusConfig(status: string): {
}
}
function getIssueTypeConfig(type: string | null | undefined): {
function getIssueTypeConfig(type: string): {
label: string;
classes: string;
icon: typeof Bug;
} {
const t = String(type ?? "").trim();
switch (t) {
switch (type) {
case "bug":
return {
label: "Bug",
@ -134,7 +133,7 @@ function getIssueTypeConfig(type: string | null | undefined): {
};
default:
return {
label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
label: type.charAt(0).toUpperCase() + type.slice(1),
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
icon: HelpCircle,
};
@ -174,10 +173,8 @@ function getRelativeTime(dateStr: string): string {
return formatDate(dateStr);
}
function formatRoleLabel(role: string | null | undefined): string {
const r = String(role ?? "").trim();
if (!r) return "—";
return r
function formatRoleLabel(role: string): string {
return role
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
@ -224,9 +221,8 @@ export function IssuesPage() {
offset: (page - 1) * pageSize,
};
const res = await getIssues(filters);
const payload = res.data?.data;
setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
setIssues(res.data.data.issues);
setTotalCount(res.data.data.total_count);
} catch (error) {
console.error("Failed to fetch issues:", error);
setIssues([]);
@ -245,7 +241,7 @@ export function IssuesPage() {
setDetailLoading(true);
try {
const res = await getIssueById(issueId);
setSelectedIssue(res.data?.data ?? null);
setSelectedIssue(res.data.data);
} catch (error) {
console.error("Failed to fetch issue detail:", error);
} finally {
@ -309,15 +305,16 @@ export function IssuesPage() {
};
// Client-side filtering (status, type, search)
const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
const filteredIssues = issues.filter((issue) => {
if (statusFilter && issue.status !== statusFilter) return false;
if (typeFilter && issue.issue_type !== typeFilter) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
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 (
issue.subject.toLowerCase().includes(q) ||
issue.description.toLowerCase().includes(q) ||
issue.issue_type.toLowerCase().includes(q)
);
}
return true;
});
@ -540,10 +537,10 @@ export function IssuesPage() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-600 truncate">
{issue.subject?.trim() ? issue.subject : "—"}
{issue.subject}
</p>
<p className="text-xs text-grayScale-400 truncate mt-0.5">
{issue.description?.trim() ? issue.description : "No description"}
{issue.description}
</p>
</div>
</div>
@ -575,9 +572,6 @@ 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>

View File

@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { 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"
@ -283,7 +282,7 @@ export function CreateNotificationPage() {
>
{sending ? (
<>
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
@ -348,7 +347,7 @@ export function CreateNotificationPage() {
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading users
</div>
)}

View File

@ -1,18 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
Plus,
Search,
Shield,
ShieldCheck,
ChevronLeft,
ChevronRight,
AlertCircle,
Eye,
X,
Pencil,
Check,
Trash2,
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
AlertCircle, Eye, X, Pencil, Check,
} from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
@ -22,14 +12,7 @@ import { Textarea } from "../../components/ui/textarea"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "../../components/ui/dialog"
import {
getRoles,
getRoleDetail,
getAllPermissions,
setRolePermissions,
updateRole,
deleteRole,
} from "../../api/rbac.api"
import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
@ -53,11 +36,6 @@ 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("")
@ -81,28 +59,27 @@ 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()
}, [fetchRoles])
}, [debouncedQuery, page, pageSize])
// Open role detail
const handleViewRole = async (roleId: number) => {
@ -120,45 +97,6 @@ export function RolesListPage() {
}
}
const handleDeleteRoleClick = (role: Role) => {
setRoleToDelete(role)
setDeleteDialogOpen(true)
}
const handleCancelDeleteRole = () => {
setDeleteDialogOpen(false)
setRoleToDelete(null)
}
const handleConfirmDeleteRole = async () => {
if (!roleToDelete) return
setDeleteLoading(true)
try {
const res = await deleteRole(roleToDelete.id)
toast.success(res.data.message ?? "Role deleted successfully")
// Close dialogs if the deleted role is currently opened.
if (selectedRole?.id === roleToDelete.id) {
setDetailOpen(false)
setSelectedRole(null)
setEditingPermissions(false)
setEditingRole(false)
setPermSearch("")
}
setRoleToDelete(null)
setDeleteDialogOpen(false)
await fetchRoles()
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Failed to delete role."
toast.error(message)
} finally {
setDeleteLoading(false)
}
}
// Enter role info edit mode
const handleEditRole = () => {
if (!selectedRole) return
@ -364,7 +302,7 @@ export function RolesListPage() {
{roles.map((role) => (
<Card
key={role.id}
className="overflow-hidden border border-grayScale-100 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md"
>
<div
className={cn(
@ -374,7 +312,7 @@ export function RolesListPage() {
: "bg-gradient-to-r from-brand-500 to-brand-600",
)}
/>
<CardContent className="space-y-4 p-5">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div
@ -392,63 +330,32 @@ export function RolesListPage() {
)}
</div>
<div>
<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."}
<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}
</p>
</div>
</div>
<Badge
variant={role.is_system ? "warning" : "outline"}
className="shrink-0 text-[10px]"
>
{role.is_system ? "System" : "Custom"}
</Badge>
{role.is_system && (
<Badge variant="warning" className="shrink-0 text-[10px]">
System
</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-2 rounded-xl border border-grayScale-100 bg-grayScale-50/70 p-2.5 text-[11px]">
<div>
<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">
<div className="mt-4 flex items-center justify-between">
<span className="text-[11px] text-grayScale-400">
Open details to view permissions
Created {new Date(role.created_at).toLocaleDateString()}
</span>
<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>
<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>
</CardContent>
</Card>
@ -782,55 +689,6 @@ 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>
)
}

View File

@ -45,7 +45,6 @@ export interface GetCoursesResponse {
export interface CreateCourseRequest {
category_id: number
sub_category_id?: number | null
title: string
description: string
}
@ -57,496 +56,6 @@ 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
}
/** Exam prep catalog course row (e.g. IELTS / DET cards) */
export interface ExamPrepCatalogCourseItem {
id: number
name: string
description?: string | null
thumbnail?: string | null
sort_order?: number
units_count?: number
modules_count?: number
lessons_count?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepCatalogCourseRequest {
name: string
description?: string | null
thumbnail?: string | null
}
export interface CreateExamPrepCatalogCourseResponse {
message: string
data: ExamPrepCatalogCourseItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepCatalogCoursesResponse {
message: string
data: {
offset: number
limit: number
total_count: number
catalog_courses: ExamPrepCatalogCourseItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepCatalogCourseRequest {
name: string
description?: string | null
thumbnail?: string | null
sort_order: number
}
export interface UpdateExamPrepCatalogCourseResponse {
message: string
data: ExamPrepCatalogCourseItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface ExamPrepCatalogUnitItem {
id: number
catalog_course_id: number
name: string
description?: string | null
thumbnail?: string | null
sort_order?: number
modules_count?: number
lessons_count?: number
videos_count?: number
practices_count?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepCatalogUnitRequest {
name: string
description?: string | null
thumbnail?: string | null
}
export interface CreateExamPrepCatalogUnitResponse {
message: string
data: ExamPrepCatalogUnitItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepCatalogUnitRequest {
name: string
description?: string | null
thumbnail?: string | null
sort_order: number
}
export interface UpdateExamPrepCatalogUnitResponse {
message: string
data: ExamPrepCatalogUnitItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepCatalogUnitsResponse {
message: string
data: {
offset: number
limit: number
total_count: number
units: ExamPrepCatalogUnitItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface ExamPrepUnitModuleItem {
id: number
unit_id: number
name: string
description?: string | null
thumbnail?: string | null
icon?: string | null
sort_order?: number
lessons_count?: number
videos_count?: number
practices_count?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepUnitModuleRequest {
name: string
description?: string | null
thumbnail?: string | null
icon?: string | null
}
export interface CreateExamPrepUnitModuleResponse {
message: string
data: ExamPrepUnitModuleItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepUnitModuleRequest {
name: string
description?: string | null
thumbnail?: string | null
icon?: string | null
sort_order: number
}
export interface UpdateExamPrepUnitModuleResponse {
message: string
data: ExamPrepUnitModuleItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepUnitModulesResponse {
message: string
data: {
offset: number
limit: number
total_count: number
modules: ExamPrepUnitModuleItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface ExamPrepModuleLessonItem {
id: number
unit_module_id: number
title: string
video_url: string
thumbnail?: string | null
description?: string | null
sort_order?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepModuleLessonRequest {
title: string
video_url: string
thumbnail?: string | null
description?: string | null
}
export interface CreateExamPrepModuleLessonResponse {
message: string
data: ExamPrepModuleLessonItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepModuleLessonRequest {
title: string
video_url?: string | null
thumbnail?: string | null
description?: string | null
sort_order: number
}
export interface UpdateExamPrepModuleLessonResponse {
message: string
data: ExamPrepModuleLessonItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepModuleLessonsResponse {
message: string
data: {
lessons: ExamPrepModuleLessonItem[]
total_count: number
limit: number
offset: number
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetProgramCoursesResponse {
message: string
data: {
total_count: number
limit: number
offset: number
courses: ProgramCourseListItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
/** Row from GET /courses/:courseId/modules (Learn English track). */
export interface TopLevelCourseModuleItem {
id: number
program_id: number
course_id: number
name: string
description: string
icon?: string | null
sort_order: number
created_at: string
}
export interface GetTopLevelCourseModulesResponse {
message: string
data: {
limit: number
offset: number
modules: TopLevelCourseModuleItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /modules/:id (Learn English top-level modules). */
export interface UpdateTopLevelCourseModuleRequest {
name: string
description: string
icon: string
}
/** Body for POST /courses/:courseId/modules */
export interface CreateTopLevelCourseModuleRequest {
name: string
description: string
icon: string
}
export interface CreateTopLevelCourseModuleResponse {
message: string
data: TopLevelCourseModuleItem
success: boolean
status_code: number
metadata: unknown | null
}
/** Row from GET /modules/:moduleId/lessons (Learn English top-level module lessons). */
export interface TopLevelModuleLessonItem {
id: number
module_id: number
title: string
video_url: string
thumbnail: string
description: string
sort_order: number
created_at: string
}
export interface GetTopLevelModuleLessonsResponse {
message: string
data: {
total_count: number
limit: number
offset: number
lessons: TopLevelModuleLessonItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
/** Practice returned by GET /courses|modules|lessons/.../practices (Learn English parent-linked practice). */
export interface ParentContextPractice {
id: number
parent_kind: string
parent_id: number
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number | null
created_at: string
}
export interface GetPracticesByParentContextResponse {
message: string
data: {
offset: number
limit: number
practices: ParentContextPractice[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown | null
}
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
export interface CreateParentLinkedPracticeRequest {
parent_kind: PracticeParentKind
parent_id: number
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number
}
export interface CreateParentLinkedPracticeResponse {
message: string
data: ParentContextPractice
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
export interface UpdateParentLinkedPracticeRequest {
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number | null
}
export interface UpdateParentLinkedPracticeResponse {
message: string
data: ParentContextPractice
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
export interface UpdateTopLevelModuleLessonRequest {
title: string
video_url: string
thumbnail: string
description: string
}
/** Body for POST /modules/:moduleId/lessons. */
export interface CreateTopLevelModuleLessonRequest {
title: string
video_url: string
thumbnail: string
description: string
}
export interface CreateTopLevelModuleLessonResponse {
message: string
data: TopLevelModuleLessonItem
success: boolean
status_code: number
metadata: unknown | null
}
// ============================================
// Legacy Types (deprecated - using SubCourse hierarchy now)
// Keeping for backward compatibility with existing API endpoints
@ -663,13 +172,7 @@ export interface GetModulesResponse {
export interface CreateModuleRequest {
level_id: number
title: 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
content: string
}
/** @deprecated Use UpdateSubCourseRequest instead */
@ -689,8 +192,6 @@ 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
@ -1200,72 +701,6 @@ 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: {
@ -1279,209 +714,10 @@ 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[]
}
@ -1492,7 +728,6 @@ export interface HumanLanguageModule {
}
export interface HumanLanguageLevelTree {
level_id?: number
level: string
modules: HumanLanguageModule[]
}

View File

@ -60,14 +60,6 @@ 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[]
}