This commit is contained in:
elnatansamuel25 2026-05-06 09:37:25 +03:00
commit 6181334db7
44 changed files with 11219 additions and 6200 deletions

View File

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

View File

@ -44,18 +44,49 @@ import type {
GetQuestionsResponse, GetQuestionsResponse,
CreateVimeoVideoRequest, CreateVimeoVideoRequest,
CreateCourseCategoryRequest, CreateCourseCategoryRequest,
GetCategorySubCategoriesResponse,
GetSubCategoryCoursesResponse,
GetSubCoursePrerequisitesResponse, GetSubCoursePrerequisitesResponse,
AddSubCoursePrerequisiteRequest, AddSubCoursePrerequisiteRequest,
GetLearningPathResponse, GetLearningPathResponse,
GetHumanLanguageLessonsResponse, GetHumanLanguageLessonsResponse,
GetHumanLanguageHierarchyResponse, GetHumanLanguageHierarchyResponse,
GetCourseHierarchyResponse,
CreateHumanLanguageLessonRequest, CreateHumanLanguageLessonRequest,
GetSubModuleLessonsResponse,
GetSubModuleLessonDetailResponse,
UpdateSubModuleLessonRequest,
UpdateSubModuleLessonResponse,
GetCourseLevelsForCourseResponse,
GetSubModulesByModuleResponse,
SubCourse,
GetSubCourseEntryAssessmentResponse, GetSubCourseEntryAssessmentResponse,
ReorderItem, ReorderItem,
GetRatingsResponse, GetRatingsResponse,
GetRatingsParams, GetRatingsParams,
GetVimeoSampleResponse, GetVimeoSampleResponse,
CreateCourseVideoRequest, CreateCourseVideoRequest,
GetLearningProgramsResponse,
UpdateLearningProgramRequest,
CreateLearningProgramRequest,
CreateLearningProgramResponse,
GetProgramCoursesResponse,
GetTopLevelCourseModulesResponse,
UpdateTopLevelCourseRequest,
UpdateTopLevelCourseModuleRequest,
CreateTopLevelCourseModuleRequest,
CreateTopLevelCourseModuleResponse,
CreateProgramCourseRequest,
CreateProgramCourseResponse,
GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse,
CreateParentLinkedPracticeRequest,
CreateParentLinkedPracticeResponse,
UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse,
UpdateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse,
} from "../types/course.types" } from "../types/course.types"
type UnifiedHierarchyRow = { type UnifiedHierarchyRow = {
@ -110,6 +141,35 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name }) ? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name })
: http.post("/course-management/categories", { name: data.name }) : http.post("/course-management/categories", { name: data.name })
export const deleteCourseCategory = (categoryId: number) =>
http.delete(`/course-management/categories/${categoryId}`)
export const getSubCategoriesByCategoryId = (categoryId: number) =>
http.get<GetCategorySubCategoriesResponse>(`/course-management/categories/${categoryId}/sub-categories`)
export const getCoursesBySubCategoryId = (subCategoryId: number) =>
http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
export const createSubCategory = (payload: {
category_id: number
name: string
description?: string | null
display_order?: number
}) => http.post("/course-management/sub-categories", payload)
export const deleteCourseSubCategory = (subCategoryId: number) =>
http.delete(`/course-management/sub-categories/${subCategoryId}`)
export const updateSubCategory = (
subCategoryId: number,
payload: Partial<{
name: string
description: string | null
is_active: boolean
display_order: number
}>,
) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
export const getCoursesByCategory = (categoryId: number) => export const getCoursesByCategory = (categoryId: number) =>
http.get("/course-management/hierarchy").then((res) => { http.get("/course-management/hierarchy").then((res) => {
const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
@ -148,9 +208,13 @@ export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
http.put(`/course-management/courses/${courseId}`, data) http.put(`/course-management/courses/${courseId}`, data)
// Sub-Module APIs (Unified Hierarchy) // Sub-Module APIs (Unified Hierarchy)
export const getCourseHierarchyByCourseId = (courseId: number) =>
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
export const getSubModulesByCourse = (courseId: number) => export const getSubModulesByCourse = (courseId: number) =>
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => { http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
const rows: CourseHierarchyRow[] = res.data?.data ?? [] const raw = res.data?.data
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
const subModuleMap = new Map<number, { id: number; course_id: number; module_id?: number; title: string; description: string; level: string; cefr_level?: string; thumbnail: string; display_order: number; sub_level?: string; is_active: boolean }>() const subModuleMap = new Map<number, { id: number; course_id: number; module_id?: number; title: string; description: string; level: string; cefr_level?: string; thumbnail: string; display_order: number; sub_level?: string; is_active: boolean }>()
rows.forEach((r, idx) => { rows.forEach((r, idx) => {
if (!r.sub_module_id) return if (!r.sub_module_id) return
@ -225,6 +289,27 @@ export const deleteSubModule = (subModuleId: number) =>
export const getVideosBySubModule = (subModuleId: number) => export const getVideosBySubModule = (subModuleId: number) =>
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`) http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) =>
http.get<GetSubModuleLessonsResponse>(`/course-management/sub-modules/${subModuleId}/lessons`, {
params: { include_inactive: options?.includeInactive ?? true },
})
export const getSubModuleLessonById = (
lessonId: number,
options?: { cacheBust?: boolean },
) =>
http.get<GetSubModuleLessonDetailResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
params: options?.cacheBust ? { _t: Date.now() } : undefined,
})
export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) =>
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, data)
export const softDeleteSubModuleLesson = (lessonId: number) =>
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
is_active: false,
})
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) => export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
http.post("/course-management/sub-module-videos", { http.post("/course-management/sub-module-videos", {
sub_module_id: data.sub_module_id ?? data.sub_course_id, sub_module_id: data.sub_module_id ?? data.sub_course_id,
@ -345,6 +430,126 @@ export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQ
export const deletePracticeQuestion = (questionId: number) => export const deletePracticeQuestion = (questionId: number) =>
http.delete(`/questions/${questionId}`) http.delete(`/questions/${questionId}`)
/** Top-level learning programs (Learn English cards, etc.) — GET /programs */
export const getLearningPrograms = (params?: { limit?: number; offset?: number }) =>
http.get<GetLearningProgramsResponse>("/programs", { params })
export const createLearningProgram = (data: CreateLearningProgramRequest) =>
http.post<CreateLearningProgramResponse>("/programs", data)
export const getProgramCourses = (
programId: number,
params?: { limit?: number; offset?: number },
) => http.get<GetProgramCoursesResponse>(`/programs/${programId}/courses`, { params })
export const createProgramCourse = (
programId: number,
data: CreateProgramCourseRequest,
) => http.post<CreateProgramCourseResponse>(`/programs/${programId}/courses`, data)
/** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data)
export const deleteTopLevelCourse = (courseId: number) =>
http.delete(`/courses/${courseId}`)
export const getTopLevelCourseModules = (
courseId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetTopLevelCourseModulesResponse>(`/courses/${courseId}/modules`, {
params,
})
/** Learn English top-level module — POST /courses/:courseId/modules */
export const createTopLevelCourseModule = (
courseId: number,
data: CreateTopLevelCourseModuleRequest,
) =>
http.post<CreateTopLevelCourseModuleResponse>(
`/courses/${courseId}/modules`,
data,
)
/** Learn English top-level module — PUT /modules/:id */
export const updateTopLevelCourseModule = (
moduleId: number,
data: UpdateTopLevelCourseModuleRequest,
) => http.put(`/modules/${moduleId}`, data)
/** Learn English top-level module — DELETE /modules/:id */
export const deleteTopLevelCourseModule = (moduleId: number) =>
http.delete(`/modules/${moduleId}`)
/** Learn English top-level module lessons — GET /modules/:moduleId/lessons */
export const getModuleLessons = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetTopLevelModuleLessonsResponse>(`/modules/${moduleId}/lessons`, {
params,
})
/** Learn English top-level module lesson — POST /modules/:moduleId/lessons */
export const createModuleLesson = (
moduleId: number,
data: CreateTopLevelModuleLessonRequest,
) =>
http.post<CreateTopLevelModuleLessonResponse>(`/modules/${moduleId}/lessons`, data)
/** Learn English top-level module lesson — PUT /lessons/:id */
export const updateTopLevelModuleLesson = (
lessonId: number,
data: UpdateTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`)
/** GET /courses/:courseId/practices — practices linked to a top-level course (at most one in normal use). */
export const getPracticesByParentCourse = (
courseId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/courses/${courseId}/practices`, { params })
/** GET /modules/:moduleId/practices */
export const getPracticesByParentModule = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/modules/${moduleId}/practices`, { params })
/** GET /lessons/:lessonId/practices */
export const getPracticesByParentLesson = (
lessonId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticesByParentContextResponse>(`/lessons/${lessonId}/practices`, { params })
/** POST /practices — create a practice (story + question set) for course / module / lesson. */
export const createParentLinkedPractice = (data: CreateParentLinkedPracticeRequest) =>
http.post<CreateParentLinkedPracticeResponse>("/practices", data)
/** PUT /practices/:id */
export const updateParentLinkedPractice = (
practiceId: number,
data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** DELETE /practices/:id */
export const deleteParentLinkedPractice = (practiceId: number) =>
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
`/practices/${practiceId}`,
)
export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) =>
http.put(`/programs/${programId}`, data)
export const deleteLearningProgram = (programId: number) => http.delete(`/programs/${programId}`)
// ============================================ // ============================================
// Legacy APIs (deprecated - using SubCourse hierarchy now) // Legacy APIs (deprecated - using SubCourse hierarchy now)
// Keeping for backward compatibility // Keeping for backward compatibility
@ -383,6 +588,74 @@ export const deleteLevel = (levelId: number) =>
export const getModulesByLevel = (levelId: number) => export const getModulesByLevel = (levelId: number) =>
http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`) http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`)
export const getCourseLevelsForCourse = (courseId: number) =>
http.get<GetCourseLevelsForCourseResponse>(`/course-management/courses/${courseId}/levels`)
export const getSubModulesByModuleId = (moduleId: number) =>
http.get<GetSubModulesByModuleResponse>(`/course-management/modules/${moduleId}/sub-modules`)
/**
* Finds a sub-module under a course by walking levels modules sub-modules APIs.
*/
export async function resolveSubModuleForCourse(
courseId: number,
subModuleId: number,
): Promise<SubCourse | null> {
try {
const levelsRes = await getCourseLevelsForCourse(courseId)
const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
const sortedLevels = [...levels].sort((a, b) => {
const o = (a.display_order ?? 0) - (b.display_order ?? 0)
if (o !== 0) return o
return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
})
const modulesNested = await Promise.all(
sortedLevels.map(async (level) => {
const modsRes = await getModulesByLevel(level.id)
const rawMods = modsRes.data?.data?.modules
const modules = Array.isArray(rawMods) ? rawMods : []
const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
return sortedMods.map((module) => ({ level, module }))
}),
)
const modulePairs = modulesNested.flat()
const bundles = await Promise.all(
modulePairs.map(async ({ level, module }) => {
const subsRes = await getSubModulesByModuleId(module.id)
const rawSubs = subsRes.data?.data?.sub_modules
const subs = Array.isArray(rawSubs) ? rawSubs : []
const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
return { level, module, subs: sortedSubs }
}),
)
for (const { level, module, subs } of bundles) {
const found = subs.find((s) => s.id === subModuleId)
if (found) {
return {
id: found.id,
course_id: courseId,
level_id: level.id,
module_id: module.id,
title: found.title,
description: found.description ?? "",
level: level.cefr_level,
cefr_level: level.cefr_level,
thumbnail: found.thumbnail ?? "",
display_order: found.display_order,
sub_level: level.cefr_level,
is_active: found.is_active,
}
}
}
} catch (e) {
console.error("resolveSubModuleForCourse failed:", e)
}
return null
}
export const createModule = (data: CreateModuleRequest) => export const createModule = (data: CreateModuleRequest) =>
http.post("/course-management/modules", data) http.post("/course-management/modules", data)

View File

@ -25,6 +25,16 @@ export interface ResolveFileUrlResponse {
success?: boolean success?: boolean
} }
export interface RefreshFileUrlResponse {
message: string
data?: {
object_key?: string
url?: string
expires_in?: number
}
success?: boolean
}
export interface UploadMediaOptions { export interface UploadMediaOptions {
title?: string title?: string
description?: string description?: string
@ -86,3 +96,8 @@ export const resolveFileUrl = (key: string) =>
params: { key }, params: { key },
}) })
export const refreshFileUrl = (reference: string) =>
http.post<RefreshFileUrlResponse>("/files/refresh-url", {
reference,
})

View File

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

View File

@ -5,6 +5,7 @@ import type {
GetRolesParams, GetRolesParams,
CreateRoleRequest, CreateRoleRequest,
CreateRoleResponse, CreateRoleResponse,
DeleteRoleResponse,
SetRolePermissionsRequest, SetRolePermissionsRequest,
GetPermissionsResponse, GetPermissionsResponse,
} from "../types/rbac.types" } from "../types/rbac.types"
@ -26,3 +27,6 @@ export const setRolePermissions = (roleId: number, data: SetRolePermissionsReque
export const getAllPermissions = () => export const getAllPermissions = () =>
http.get<GetPermissionsResponse>("/rbac/permissions") http.get<GetPermissionsResponse>("/rbac/permissions")
export const deleteRole = (roleId: number) =>
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)

View File

@ -3,7 +3,6 @@ import { AppLayout } from "../layouts/AppLayout";
import { DashboardPage } from "../pages/DashboardPage"; import { DashboardPage } from "../pages/DashboardPage";
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"; import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"; import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage";
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"; import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"; import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"; import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
@ -47,7 +46,7 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"; import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
import { QuestionsPage } from "../pages/content-management/QuestionsPage"; import { QuestionsPage } from "../pages/content-management/QuestionsPage";
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"; import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"; import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage";
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"; import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
import { UserLogPage } from "../pages/user-log/UserLogPage"; import { UserLogPage } from "../pages/user-log/UserLogPage";
import { IssuesPage } from "../pages/issues/IssuesPage"; import { IssuesPage } from "../pages/issues/IssuesPage";
@ -91,10 +90,10 @@ export function AppRoutes() {
</Route> </Route>
<Route path="/content" element={<ContentManagementLayout />}> <Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<CourseCategoryPage />} /> <Route index element={<Navigate to="practices" replace />} />
<Route path="courses" element={<AllCoursesPage />} /> <Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} /> <Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguagePage />} /> <Route path="human-language" element={<HumanLanguageHierarchyPage />} />
<Route <Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice" path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
element={<AddNewPracticePage />} element={<AddNewPracticePage />}

View File

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

View File

@ -1,16 +1,18 @@
import { useState, useCallback } from "react" import { useState, useCallback, useEffect, useMemo, useRef } from "react"
import { Navigate, Outlet } from "react-router-dom" import { Navigate, Outlet, useLocation } from "react-router-dom"
import { Sidebar } from "../components/sidebar/Sidebar" import { Sidebar } from "../components/sidebar/Sidebar"
import { Topbar } from "../components/topbar/Topbar" import { Topbar } from "../components/topbar/Topbar"
export function AppLayout() { export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const mainRef = useRef<HTMLElement | null>(null)
const previousRouteKeyRef = useRef<string>("")
const location = useLocation()
const scrollStoragePrefix = "app:scroll:"
const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
const token = localStorage.getItem("access_token") const token = localStorage.getItem("access_token")
if (!token) {
return <Navigate to="/login" replace />
}
const handleSidebarToggle = useCallback(() => { const handleSidebarToggle = useCallback(() => {
setSidebarOpen((prev) => !prev) setSidebarOpen((prev) => !prev)
@ -20,6 +22,43 @@ export function AppLayout() {
setSidebarOpen(false) setSidebarOpen(false)
}, []) }, [])
useEffect(() => {
const container = mainRef.current
if (!container) return
const saveScroll = (key: string) => {
sessionStorage.setItem(`${scrollStoragePrefix}${key}`, String(container.scrollTop || 0))
}
const previousKey = previousRouteKeyRef.current
if (previousKey && previousKey !== routeKey) {
saveScroll(previousKey)
}
previousRouteKeyRef.current = routeKey
const restoreRaw = sessionStorage.getItem(`${scrollStoragePrefix}${routeKey}`)
const restoreTop = restoreRaw ? Number(restoreRaw) : 0
const top = Number.isFinite(restoreTop) && restoreTop > 0 ? restoreTop : 0
requestAnimationFrame(() => {
container.scrollTo({ top, behavior: "auto" })
})
const onScroll = () => saveScroll(routeKey)
const onBeforeUnload = () => saveScroll(routeKey)
container.addEventListener("scroll", onScroll, { passive: true })
window.addEventListener("beforeunload", onBeforeUnload)
return () => {
saveScroll(routeKey)
container.removeEventListener("scroll", onScroll)
window.removeEventListener("beforeunload", onBeforeUnload)
}
}, [routeKey])
if (!token) {
return <Navigate to="/login" replace />
}
return ( return (
<div className="flex min-h-screen bg-grayScale-100"> <div className="flex min-h-screen bg-grayScale-100">
<Sidebar <Sidebar
@ -34,7 +73,7 @@ export function AppLayout() {
}`} }`}
> >
<Topbar onSidebarToggle={handleSidebarToggle} /> <Topbar onSidebarToggle={handleSidebarToggle} />
<main className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6"> <main ref={mainRef} className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
<Outlet /> <Outlet />
</main> </main>
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6"> <footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">

13
src/lib/sessionRole.ts Normal file
View File

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

124
src/lib/videoPreview.ts Normal file
View File

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

View File

@ -67,9 +67,6 @@ export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
if (token) {
return <Navigate to="/dashboard" replace />;
}
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -162,6 +159,10 @@ export function LoginPage() {
} }
}, [googleReady, handleGoogleCallback]); }, [googleReady, handleGoogleCallback]);
if (token) {
return <Navigate to="/dashboard" replace />;
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View File

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

View File

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

View File

@ -28,7 +28,7 @@ import {
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
type CourseWithCategory = Course & { category_name: string } type CourseWithCategory = Course & { category_name: string }
@ -230,10 +230,7 @@ export function AllCoursesPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-white shadow-sm p-6"> <img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<SpinnerIcon className="h-10 w-10" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories</p>
</div> </div>
) )
} }

View File

@ -1,19 +1,8 @@
import { NavLink, Outlet } from "react-router-dom" import { 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() { export function ContentManagementLayout() {
return ( return (
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" /> <div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
@ -22,38 +11,12 @@ export function ContentManagementLayout() {
Content Management Content Management
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500"> <p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
Manage courses, speaking exercises, practices, and questions View and manage practice content for courses, modules, and lessons
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Tab bar */}
<div
className="scroll-hide mb-8 flex items-center gap-1 overflow-x-auto rounded-2xl border border-grayScale-100 bg-grayScale-50/60 p-1.5 shadow-sm backdrop-blur"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
<style>{`.scroll-hide::-webkit-scrollbar { display: none; }`}</style>
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.to === "/content"}
className={({ isActive }) =>
cn(
"relative whitespace-nowrap rounded-xl px-5 py-2 text-sm font-semibold transition-all duration-200 ease-in-out",
"text-grayScale-500 hover:bg-white/80 hover:text-brand-600 hover:shadow-sm",
isActive &&
"bg-brand-500 text-white shadow-md shadow-brand-500/25 hover:bg-brand-600 hover:text-white",
)
}
>
{t.label}
</NavLink>
))}
</div>
{/* Page content */}
<Outlet /> <Outlet />
</div> </div>
) )

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react" import { FolderOpen, RefreshCw, BookOpen, Plus, Trash2 } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg" import alertSrc from "../../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -11,10 +11,11 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../../components/ui/dialog" } from "../../components/ui/dialog"
import { getCourseCategories, createCourseCategory } from "../../api/courses.api" import { getCourseCategories, createCourseCategory, deleteCourseCategory } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types" import type { CourseCategory } from "../../types/course.types"
import { toast } from "sonner" import { toast } from "sonner"
@ -29,6 +30,8 @@ export function CourseCategoryPage() {
const [newSubCategoryName, setNewSubCategoryName] = useState("") const [newSubCategoryName, setNewSubCategoryName] = useState("")
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([]) const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [deleteTarget, setDeleteTarget] = useState<CourseCategory | null>(null)
const [deleting, setDeleting] = useState(false)
const fetchCategories = async () => { const fetchCategories = async () => {
setLoading(true) setLoading(true)
@ -164,12 +167,26 @@ export function CourseCategoryPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600"> <span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View Sub-categories View Sub-categories
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1"> <span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
</span> </span>
</span> </span>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-red-200 bg-white text-red-500 hover:bg-red-50"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setDeleteTarget(category)
}}
aria-label={`Delete category ${category.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
@ -335,7 +352,7 @@ export function CourseCategoryPage() {
if (createdCategoryId && pendingSubCategories.length > 0) { if (createdCategoryId && pendingSubCategories.length > 0) {
await Promise.all( await Promise.all(
pendingSubCategories.map((subName) => pendingSubCategories.map((subName) =>
createCourseCategory({ name: subName }), createCourseCategory({ name: subName, parent_id: createdCategoryId }),
), ),
) )
} }
@ -371,6 +388,46 @@ export function CourseCategoryPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete category?</DialogTitle>
<DialogDescription>
{deleteTarget
? `This will permanently delete "${deleteTarget.name}" and all linked sub-categories/courses.`
: ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button
type="button"
className="bg-red-600 text-white hover:bg-red-700"
disabled={deleting}
onClick={async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await deleteCourseCategory(deleteTarget.id)
toast.success("Category deleted")
setDeleteTarget(null)
await fetchCategories()
} catch (err: any) {
const message = err?.response?.data?.message || "Failed to delete category."
toast.error("Could not delete category", { description: message })
} finally {
setDeleting(false)
}
}}
>
{deleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@ -1,67 +1,362 @@
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Plus, Calendar, Plane, Clock, Hand } from "lucide-react"; import {
ArrowLeft,
Plus,
Calendar,
Layers,
Pencil,
Trash2,
X,
} from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card"; import { Card } from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
const MODULES = [ import alertSrc from "../../assets/Alert.svg";
{ import {
id: "m1", deleteTopLevelCourseModule,
title: "Introduction Basics", getProgramCourses,
description: "Learn basic English words, phrases, and simple sentences.", getTopLevelCourseModules,
icon: Hand, updateTopLevelCourseModule,
status: "Published", } from "../../api/courses.api";
gradient: "from-[#8E44AD] to-[#C39BD3]", import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
}, import type {
{ ProgramCourseListItem,
id: "m2", TopLevelCourseModuleItem,
title: "Daily Routines", } from "../../types/course.types";
description: "Vocabulary related to waking up, and evening activities.",
icon: Clock,
status: "Draft",
gradient: "from-[#8E44AD] to-[#C39BD3]",
},
{
id: "m3",
title: "Travel Essentials",
description:
"Key phrases for airports, hotels, and asking for help while abroad.",
icon: Plane,
status: "Draft",
gradient: "from-[#8E44AD] to-[#C39BD3]",
},
];
import { AddModuleModal } from "./components/AddModuleModal"; import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
function isLikelyImageUrl(src: string): boolean {
const t = src.trim();
return (
t.startsWith("http://") ||
t.startsWith("https://") ||
t.startsWith("/") ||
t.startsWith("data:")
);
}
function isSignedMinioUrl(src: string): boolean {
const value = src.trim();
if (!value.startsWith("http://") && !value.startsWith("https://"))
return false;
try {
const url = new URL(value);
return url.searchParams.has("X-Amz-Signature");
} catch {
return false;
}
}
/** Default purple gradient with optional cover image; gradient stays if URL missing or image errors. */
function ModuleCardTopMedia({ iconSrc }: { iconSrc: string }) {
const [coverFailed, setCoverFailed] = useState(false);
const tryCover =
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !coverFailed;
return (
<div className="relative h-36 w-full overflow-hidden">
<div
className={cn(
"absolute inset-0 bg-gradient-to-b opacity-90 transition-transform duration-700",
MODULE_CARD_GRADIENT,
)}
/>
{tryCover ? (
<img
src={iconSrc.trim()}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setCoverFailed(true)}
/>
) : null}
</div>
);
}
/** Circular module icon: image when load succeeds, otherwise default Layers icon. */
function ModuleIconCircle({
iconSrc,
index,
}: {
iconSrc: string;
index: number;
}) {
const [imgFailed, setImgFailed] = useState(false);
const showImg =
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !imgFailed;
return (
<div
className={cn(
"flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-purple-100/50 p-2",
index % 2 === 1 ? "bg-[#F8FAFC]" : "bg-[#f3e8ff]",
)}
>
{showImg ? (
<img
src={iconSrc.trim()}
alt=""
className="h-full w-full object-contain"
onError={() => setImgFailed(true)}
/>
) : (
<Layers
className={cn(
"h-6 w-6",
index % 2 === 1 ? "text-[#64748B]" : "text-brand-500",
)}
/>
)}
</div>
);
}
export function CourseDetailPage() { export function CourseDetailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { level, courseId } = useParams<{ level: string; courseId: string }>(); const { level: programIdParam, courseId: courseIdParam } = useParams<{
level: string;
courseId: string;
}>();
const programId = Number(programIdParam);
const courseIdNum = Number(courseIdParam);
const [course, setCourse] = useState<ProgramCourseListItem | null>(null);
const [modules, setModules] = useState<TopLevelCourseModuleItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAddModuleOpen, setIsAddModuleOpen] = useState(false); const [isAddModuleOpen, setIsAddModuleOpen] = useState(false);
const [editingModule, setEditingModule] =
useState<TopLevelCourseModuleItem | null>(null);
const [editModuleName, setEditModuleName] = useState("");
const [editModuleDescription, setEditModuleDescription] = useState("");
const [editModuleIcon, setEditModuleIcon] = useState("");
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
useState(false);
const [savingModuleEdit, setSavingModuleEdit] = useState(false);
const [deletingModule, setDeletingModule] =
useState<TopLevelCourseModuleItem | null>(null);
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module);
setEditModuleName(module.name ?? "");
setEditModuleDescription(module.description ?? "");
setEditModuleIcon(module.icon?.trim() ?? "");
setEditModuleIconUploadBusy(false);
};
const closeEditModule = () => {
if (savingModuleEdit || editModuleIconUploadBusy) return;
setEditingModule(null);
setEditModuleIconUploadBusy(false);
};
const loadPage = useCallback(async () => {
if (!Number.isFinite(programId) || programId < 1) {
setError("Invalid program");
setCourse(null);
setModules([]);
setLoading(false);
return;
}
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
setError("Invalid course");
setCourse(null);
setModules([]);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [courseOutcome, modulesOutcome] = await Promise.allSettled([
getProgramCourses(programId, { limit: 200, offset: 0 }),
getTopLevelCourseModules(courseIdNum, { limit: 100, offset: 0 }),
]);
if (courseOutcome.status === "fulfilled") {
const raw = courseOutcome.value.data?.data?.courses;
const list = Array.isArray(raw) ? raw : [];
const found = list.find((c) => c.id === courseIdNum) ?? null;
setCourse(found);
if (!found) {
setError("Course not found in this program");
}
} else {
console.error(courseOutcome.reason);
setCourse(null);
setError("Failed to load course");
}
if (modulesOutcome.status === "fulfilled") {
const raw = modulesOutcome.value.data?.data?.modules;
const list = Array.isArray(raw) ? raw : [];
const refreshed = await Promise.all(
list.map(async (module) => {
const icon = module.icon?.trim() ?? "";
if (!icon) return module;
try {
if (isSignedMinioUrl(icon)) {
const refreshedRes = await refreshFileUrl(icon);
const refreshedUrl = refreshedRes.data?.data?.url?.trim();
if (refreshedUrl) {
return { ...module, icon: refreshedUrl };
}
return module;
}
if (isLikelyImageUrl(icon)) return module;
const resolved = await resolveFileUrl(icon);
const freshUrl = resolved.data?.data?.url?.trim();
if (!freshUrl) return module;
return { ...module, icon: freshUrl };
} catch {
return module;
}
}),
);
const sorted = [...refreshed].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
);
setModules(sorted);
} else {
console.error(modulesOutcome.reason);
setModules([]);
toast.error("Could not load modules", {
description: "Check your connection or try again.",
});
}
} catch (e) {
console.error(e);
setError("Failed to load course");
setCourse(null);
setModules([]);
toast.error("Could not load course", {
description: "Check your connection or try again.",
});
} finally {
setLoading(false);
}
}, [programId, courseIdNum]);
useEffect(() => {
void loadPage();
}, [loadPage]);
const handleSaveModuleEdit = async () => {
if (!editingModule) return;
const name = editModuleName.trim();
if (!name) {
toast.error("Module name is required");
return;
}
setSavingModuleEdit(true);
try {
await updateTopLevelCourseModule(editingModule.id, {
name,
description: editModuleDescription.trim(),
icon: editModuleIcon.trim(),
});
toast.success("Module updated");
setEditModuleIconUploadBusy(false);
setEditingModule(null);
await loadPage();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update module";
toast.error(msg);
} finally {
setSavingModuleEdit(false);
}
};
const handleConfirmDeleteModule = async () => {
if (!deletingModule) return;
setDeletingModuleInFlight(true);
try {
await deleteTopLevelCourseModule(deletingModule.id);
toast.success("Module deleted");
setDeletingModule(null);
await loadPage();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete module";
toast.error(msg);
} finally {
setDeletingModuleInFlight(false);
}
};
const displayTitle = course?.name?.trim() || courseIdParam || "Course";
const displayDescription =
course?.description?.trim() ||
(!loading && !course
? "This course could not be loaded."
: !course?.description?.trim() && course
? "—"
: "");
return ( return (
<div className="space-y-10 pb-20 pt-10"> <div className="space-y-10 pb-20 pt-10">
{/* Header Navigation */} {/* Header Navigation */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
to={`/new-content/learn-english/${level}/courses`} to={`/new-content/learn-english/${programIdParam}/courses`}
className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500" className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500"
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
Back to Levels Back to Courses
</Link> </Link>
</div> </div>
{loading ? (
<div className="flex flex-col items-center justify-center py-16">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
</div>
) : error && !course ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
<img src={alertSrc} alt="" className="h-10 w-10" />
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
<Button
type="button"
variant="outline"
className="mt-4"
onClick={() => void loadPage()}
>
Try again
</Button>
</div>
) : (
<>
{/* Hero Section */} {/* Hero Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6"> <div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
<div className=""> <div className="min-w-0 flex-1">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight"> <h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
{courseId?.toUpperCase() || "A1"} {displayTitle}
</h1> </h1>
<p className="text-grayScale-500 text-sm max-w-2xl font-medium"> <p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
Learn basic English words, phrases, and simple sentences for daily {displayDescription}
situations.
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -70,7 +365,7 @@ export function CourseDetailPage() {
className="rounded-[6px] border-brand-500 text-brand-500 " className="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() => onClick={() =>
navigate( navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=modules&courseId=${courseId}`, `/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
) )
} }
> >
@ -86,81 +381,254 @@ export function CourseDetailPage() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full rounded-full opacity-20"
style={{
background: "gray",
}}
/>
</div>
</div>
<AddModuleModal <AddModuleModal
isOpen={isAddModuleOpen} isOpen={isAddModuleOpen}
onClose={() => setIsAddModuleOpen(false)} onClose={() => setIsAddModuleOpen(false)}
/> courseId={courseIdNum}
{/* Gradient Divider */} onCreated={() => loadPage()}
{/* Gradient Grid */}
<div className="flex flex-warp gap-10">
{MODULES.map((module) => (
<Card
key={module.id}
className="group overflow-hidden border w-[330px] border-grayScale-50 shadow-sm hover:shadow-lg transition-all duration-300 rounded-[16px] bg-white flex flex-col h-full"
>
{/* Gradient Banner */}
<div
className={cn(
"h-36 w-full bg-gradient-to-b opacity-90 transition-transform duration-700",
module.gradient,
)}
/> />
<div className="p-2 pb-4 pt-4 flex-1 flex flex-col"> <Dialog
<div className="flex gap-4 mb-8"> open={editingModule !== null}
{/* Icon Circle */} onOpenChange={(open) => {
<div if (!open && savingModuleEdit) return;
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`} if (!open && editModuleIconUploadBusy) return;
if (!open) closeEditModule();
}}
> >
<module.icon <DialogContent className="max-w-lg">
className={`h-6 w-6 ${module.id === "m2" ? "text-[#64748B]" : "text-brand-500"}`} <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>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Description
</label>
<Textarea
value={editModuleDescription}
onChange={(e) => setEditModuleDescription(e.target.value)}
rows={4}
className="min-h-[100px] resize-y rounded-xl"
placeholder="Optional short description."
disabled={savingModuleEdit}
/>
</div>
<ModuleIconUploadField
value={editModuleIcon}
onChange={setEditModuleIcon}
disabled={savingModuleEdit}
onUploadBusyChange={setEditModuleIconUploadBusy}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={closeEditModule}
disabled={savingModuleEdit || editModuleIconUploadBusy}
>
Cancel
</Button>
<Button
type="button"
className="bg-brand-500 hover:bg-brand-600"
disabled={savingModuleEdit || editModuleIconUploadBusy}
onClick={() => void handleSaveModuleEdit()}
>
{savingModuleEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Content */} {modules.length === 0 ? (
<div className="space-y-1"> <div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<h3 className="text-lg font-bold text-[#0F172A] tracking-tight"> <p className="text-sm font-medium text-grayScale-600">
{module.title} No modules in this course yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Add modules when your workflow is connected, or create them via
the API.
</p>
</div>
) : (
<div
className="grid justify-start gap-10"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
}}
>
{modules.map((module, index) => {
const iconSrc = module.icon?.trim() ?? "";
return (
<Card
key={module.id}
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
>
<div className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto">
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${module.name}`}
onClick={() => openEditModule(module)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${module.name}`}
onClick={() => setDeletingModule(module)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<ModuleCardTopMedia iconSrc={iconSrc} />
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
<div className="flex min-h-0 flex-1 gap-4">
<ModuleIconCircle iconSrc={iconSrc} index={index} />
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name}
</h3> </h3>
<p className="text-grayScale-400 font-medium text-[12px]"> <p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
{module.description} {module.description?.trim()
? module.description
: "—"}
</p> </p>
</div> </div>
</div> </div>
{/* Actions */} <div className="mt-auto flex shrink-0 items-center gap-3">
<div className="flex items-center gap-3 mt-auto">
<Button <Button
variant="outline" variant="outline"
className="flex-1 h-10 rounded-[6px] border-[#9E2891] text-[#9E2891] transition-all text-sm" className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
onClick={() => onClick={() =>
navigate( navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${module.id}`, `/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
{
state: {
moduleName: module.name,
moduleDescription:
module.description?.trim() ?? "",
},
},
) )
} }
> >
View Detail View Detail
</Button> </Button>
{module.status === "Published" ? ( <Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
<Button
disabled
className="flex-1 h-10 rounded-[6px] bg-[#D291BC] text-white opacity-100 cursor-default border-none shadow-none text-sm"
>
Published
</Button>
) : (
<Button className="flex-1 h-10 rounded-[6px] bg-brand-500 text-white shadow-md shadow-brand-500/10 text-sm">
Publish Practice Publish Practice
</Button> </Button>
)}
</div> </div>
</div> </div>
</Card> </Card>
))} );
})}
</div> </div>
)}
{deletingModule && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">
Delete module
</h2>
<button
type="button"
onClick={() =>
!deletingModuleInFlight && setDeletingModule(null)
}
disabled={deletingModuleInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{deletingModule.name}
</span>
? This cannot be undone. Related content may be affected
depending on your backend.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingModule(null)}
disabled={deletingModuleInFlight}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deletingModuleInFlight}
onClick={() => void handleConfirmDeleteModule()}
>
{deletingModuleInFlight ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</>
)}
</div> </div>
); );
} }

View File

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

View File

@ -1,14 +1,27 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" import { Link, useParams, useNavigate } from "react-router-dom"
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react" import {
Plus,
ArrowLeft,
ToggleLeft,
ToggleRight,
X,
Trash2,
Edit,
AlertCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
Search,
BookOpen,
Eye,
} from "lucide-react"
import practiceSrc from "../../assets/Practice.svg" import practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg" import alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { FileUpload } from "../../components/ui/file-upload"
import { import {
Table, Table,
TableBody, TableBody,
@ -18,24 +31,19 @@ import {
TableRow, TableRow,
} from "../../components/ui/table" } from "../../components/ui/table"
import { import {
getCoursesByCategory, getSubCategoriesByCategoryId,
getCourseCategories, getCourseCategories,
createCourse, createSubCategory,
deleteCourse, deleteCourseSubCategory,
updateCourseStatus, updateSubCategory,
updateCourse,
updateCourseThumbnail,
getRatings,
} from "../../api/courses.api" } from "../../api/courses.api"
import { uploadImageFile } from "../../api/files.api" import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import type { Course, CourseCategory, Rating } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function CoursesPage() { export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [courses, setCourses] = useState<Course[]>([]) const [subCategories, setSubCategories] = useState<CategorySubCategoryListItem[]>([])
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [category, setCategory] = useState<CourseCategory | null>(null) const [category, setCategory] = useState<CourseCategory | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -44,36 +52,32 @@ export function CoursesPage() {
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [displayOrder, setDisplayOrder] = useState("")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null) const [subCategoryToDelete, setSubCategoryToDelete] = useState<CategorySubCategoryListItem | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null) const [togglingId, setTogglingId] = useState<number | null>(null)
const [showEditModal, setShowEditModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false)
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null) const [subCategoryToEdit, setSubCategoryToEdit] = useState<CategorySubCategoryListItem | null>(null)
const [editTitle, setEditTitle] = useState("") const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("") const [editDescription, setEditDescription] = useState("")
const [editThumbnail, setEditThumbnail] = useState("") const [editDisplayOrder, setEditDisplayOrder] = useState(0)
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null)
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null) const [updateError, setUpdateError] = useState<string | null>(null)
const [showRatingsModal, setShowRatingsModal] = useState(false)
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const fetchCourses = async () => { const fetchSubCategories = async () => {
if (!categoryId) return if (!categoryId) return
try { try {
const coursesRes = await getCoursesByCategory(Number(categoryId)) const res = await getSubCategoriesByCategoryId(Number(categoryId))
console.log("Courses response:", coursesRes.data.data.courses) const raw = res.data?.data?.sub_categories
setCourses(coursesRes.data.data.courses ?? []) setSubCategories(Array.isArray(raw) ? raw : [])
} catch (err) { } catch (err) {
console.error("Failed to fetch courses:", err) console.error("Failed to fetch sub-categories:", err)
} }
} }
@ -82,18 +86,19 @@ export function CoursesPage() {
if (!categoryId) return if (!categoryId) return
try { try {
const [coursesRes, categoriesRes] = await Promise.all([ const [subRes, categoriesRes] = await Promise.all([
getCoursesByCategory(Number(categoryId)), getSubCategoriesByCategoryId(Number(categoryId)),
getCourseCategories(), getCourseCategories(),
]) ])
setCourses(coursesRes.data.data.courses ?? []) const raw = subRes.data?.data?.sub_categories
const foundCategory = categoriesRes.data.data.categories.find( setSubCategories(Array.isArray(raw) ? raw : [])
(c) => c.id === Number(categoryId) const foundCategory = categoriesRes.data?.data?.categories?.find(
(c) => c.id === Number(categoryId),
) )
setCategory(foundCategory ?? null) setCategory(foundCategory ?? null)
} catch (err) { } catch (err) {
console.error("Failed to fetch courses:", err) console.error("Failed to fetch sub-categories:", err)
setError("Failed to load sub-categories") setError("Failed to load sub-categories")
} finally { } finally {
setLoading(false) setLoading(false)
@ -110,6 +115,7 @@ export function CoursesPage() {
const handleOpenModal = () => { const handleOpenModal = () => {
setTitle("") setTitle("")
setDescription("") setDescription("")
setDisplayOrder("")
setSaveError(null) setSaveError(null)
setShowModal(true) setShowModal(true)
} }
@ -118,16 +124,13 @@ export function CoursesPage() {
setShowModal(false) setShowModal(false)
setTitle("") setTitle("")
setDescription("") setDescription("")
setDisplayOrder("")
setSaveError(null) setSaveError(null)
} }
const handleSave = async () => { const handleSave = async () => {
if (!title.trim()) { if (!title.trim()) {
setSaveError("Title is required") setSaveError("Name is required")
return
}
if (!description.trim()) {
setSaveError("Description is required")
return return
} }
@ -135,13 +138,15 @@ export function CoursesPage() {
setSaveError(null) setSaveError(null)
try { try {
await createCourse({ const orderParsed = parseInt(displayOrder.trim(), 10)
await createSubCategory({
category_id: Number(categoryId), category_id: Number(categoryId),
title: title.trim(), name: title.trim(),
description: description.trim(), description: description.trim() || null,
...(Number.isFinite(orderParsed) && orderParsed >= 0 ? { display_order: orderParsed } : {}),
}) })
handleCloseModal() handleCloseModal()
await fetchCourses() await fetchSubCategories()
} catch (err: any) { } catch (err: any) {
console.error("Failed to create course:", err) console.error("Failed to create course:", err)
setSaveError(err.response?.data?.message || "Failed to create sub-category") setSaveError(err.response?.data?.message || "Failed to create sub-category")
@ -150,20 +155,20 @@ export function CoursesPage() {
} }
} }
const handleDeleteClick = (course: Course) => { const handleDeleteClick = (sub: CategorySubCategoryListItem) => {
setCourseToDelete(course) setSubCategoryToDelete(sub)
setShowDeleteModal(true) setShowDeleteModal(true)
} }
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
if (!courseToDelete) return if (!subCategoryToDelete) return
setDeleting(true) setDeleting(true)
try { try {
await deleteCourse(courseToDelete.id) await deleteCourseSubCategory(subCategoryToDelete.id)
setShowDeleteModal(false) setShowDeleteModal(false)
setCourseToDelete(null) setSubCategoryToDelete(null)
await fetchCourses() await fetchSubCategories()
} catch (err) { } catch (err) {
console.error("Failed to delete course:", err) console.error("Failed to delete course:", err)
} finally { } finally {
@ -171,11 +176,11 @@ export function CoursesPage() {
} }
} }
const handleToggleStatus = async (course: Course) => { const handleToggleStatus = async (sub: CategorySubCategoryListItem) => {
setTogglingId(course.id) setTogglingId(sub.id)
try { try {
await updateCourseStatus(course.id, !course.is_active) await updateSubCategory(sub.id, { is_active: !sub.is_active })
await fetchCourses() await fetchSubCategories()
} catch (err) { } catch (err) {
console.error("Failed to update course status:", err) console.error("Failed to update course status:", err)
} finally { } finally {
@ -183,35 +188,29 @@ export function CoursesPage() {
} }
} }
const handleEditClick = (course: Course) => { const handleEditClick = (sub: CategorySubCategoryListItem) => {
setCourseToEdit(course) setSubCategoryToEdit(sub)
setEditTitle(course.title || "") setEditTitle(sub.name || "")
setEditDescription(course.description || "") setEditDescription(sub.description ?? "")
setEditThumbnail(course.thumbnail || "") setEditDisplayOrder(sub.display_order ?? 0)
setEditThumbnailFile(null)
setUpdateError(null) setUpdateError(null)
setShowEditModal(true) setShowEditModal(true)
} }
const handleCloseEditModal = () => { const handleCloseEditModal = () => {
setShowEditModal(false) setShowEditModal(false)
setCourseToEdit(null) setSubCategoryToEdit(null)
setEditTitle("") setEditTitle("")
setEditDescription("") setEditDescription("")
setEditThumbnail("") setEditDisplayOrder(0)
setEditThumbnailFile(null)
setUpdateError(null) setUpdateError(null)
} }
const handleUpdate = async () => { const handleUpdate = async () => {
if (!courseToEdit) return if (!subCategoryToEdit) return
if (!editTitle.trim()) { if (!editTitle.trim()) {
setUpdateError("Title is required") setUpdateError("Name is required")
return
}
if (!editDescription.trim()) {
setUpdateError("Description is required")
return return
} }
@ -219,23 +218,15 @@ export function CoursesPage() {
setUpdateError(null) setUpdateError(null)
try { try {
await updateCourse(courseToEdit.id, { await updateSubCategory(subCategoryToEdit.id, {
title: editTitle.trim(), name: editTitle.trim(),
description: editDescription.trim(), description: editDescription.trim() || null,
is_active: courseToEdit.is_active, display_order: Math.max(0, Number(editDisplayOrder) || 0),
is_active: subCategoryToEdit.is_active,
}) })
const thumbnailUrl =
editThumbnailFile
? (await uploadImageFile(editThumbnailFile)).data?.data?.url?.trim()
: editThumbnail.trim() || ""
if (thumbnailUrl) {
await updateCourseThumbnail(courseToEdit.id, thumbnailUrl)
}
handleCloseEditModal() handleCloseEditModal()
await fetchCourses() await fetchSubCategories()
} catch (err: any) { } catch (err: any) {
console.error("Failed to update course:", err) console.error("Failed to update course:", err)
setUpdateError(err.response?.data?.message || "Failed to update sub-category") setUpdateError(err.response?.data?.message || "Failed to update sub-category")
@ -244,32 +235,19 @@ export function CoursesPage() {
} }
} }
const handleCourseClick = (courseId: number) => { const handleOpenSubCategory = (subCategoryId: number) => {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-modules`) navigate(`/content/category/${categoryId}/sub-categories/${subCategoryId}/courses`)
} }
const handleViewRatings = async (courseId: number) => { const filteredSubCategories = useMemo(() => {
setRatingsCourseId(courseId)
setShowRatingsModal(true)
setCourseRatingsLoading(true)
try {
const res = await getRatings({ target_type: "course", target_id: courseId, limit: 10 })
setCourseRatings(res.data.data ?? [])
} catch (err) {
console.error("Failed to fetch ratings:", err)
} finally {
setCourseRatingsLoading(false)
}
}
const filteredCourses = useMemo(() => {
const q = searchQuery.trim().toLowerCase() const q = searchQuery.trim().toLowerCase()
if (!q) return courses if (!q) return subCategories
return courses.filter((course) => { return subCategories.filter((sub) => {
const haystack = `${course.title} ${course.description ?? ""} ${course.id}`.toLowerCase() const haystack =
`${sub.name} ${sub.description ?? ""} ${sub.category_name} ${sub.id} ${sub.display_order}`.toLowerCase()
return haystack.includes(q) return haystack.includes(q)
}) })
}, [courses, searchQuery]) }, [subCategories, searchQuery])
if (loading) { if (loading) {
return ( return (
@ -290,13 +268,29 @@ export function CoursesPage() {
) )
} }
const totalCount = filteredCourses.length const totalCount = filteredSubCategories.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)) const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages) const safePage = Math.min(page, totalPages)
const paginatedCourses = filteredCourses.slice((safePage - 1) * pageSize, safePage * pageSize) const paginatedSubCategories = filteredSubCategories.slice((safePage - 1) * pageSize, safePage * pageSize)
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1 const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount) const endEntry = Math.min(safePage * pageSize, totalCount)
const formatId = (id: number) => `#${id}`
const formatCreatedAt = (iso: string) => {
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
} catch {
return iso
}
}
const getPageNumbers = () => { const getPageNumbers = () => {
const pages: (number | string)[] = [] const pages: (number | string)[] = []
if (totalPages <= 7) { if (totalPages <= 7) {
@ -328,7 +322,7 @@ export function CoursesPage() {
{category?.name} Sub-categories {category?.name} Sub-categories
</h1> </h1>
<p className="mt-0.5 text-sm text-grayScale-400"> <p className="mt-0.5 text-sm text-grayScale-400">
<span className="font-medium text-grayScale-500">{courses.length}</span> sub-categories available <span className="font-medium text-grayScale-500">{subCategories.length}</span> sub-categories available
</p> </p>
</div> </div>
</div> </div>
@ -339,15 +333,11 @@ export function CoursesPage() {
</div> </div>
</div> </div>
{/* Course table or empty state */} {/* Sub-category table — layout aligned with Activity Log (UserLogPage) */}
<Card className="shadow-soft"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-white p-4">
<CardHeader className="border-b border-grayScale-200 pb-3"> <h2 className="text-base font-semibold text-grayScale-600">Sub-category Management</h2>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="relative w-full min-w-[200px] flex-1 sm:max-w-xs sm:flex-initial">
<CardTitle className="text-base font-semibold text-grayScale-600"> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
Sub-category Management
</CardTitle>
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@ -356,15 +346,13 @@ export function CoursesPage() {
/> />
</div> </div>
</div> </div>
</CardHeader>
<CardContent className="pt-4"> {subCategories.length === 0 ? (
{courses.length === 0 ? ( <div className="rounded-xl border bg-white">
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center"> <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<img src={practiceSrc} alt="" className="h-16 w-16" /> <img src={practiceSrc} alt="" className="h-16 w-16" />
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3> <h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3>
<p className="mt-1.5 text-sm text-grayScale-400"> <p className="mt-1.5 text-sm text-grayScale-400">No sub-categories found in this category.</p>
No sub-categories found in this category.
</p>
<Button <Button
variant="outline" variant="outline"
className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50" className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50"
@ -374,60 +362,90 @@ export function CoursesPage() {
Add your first sub-category Add your first sub-category
</Button> </Button>
</div> </div>
) : filteredCourses.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
<p className="text-base font-semibold text-grayScale-600">No matching sub-categories</p>
<p className="mt-1.5 text-sm text-grayScale-400">
Try a different search term.
</p>
</div> </div>
) : ( ) : (
<div className="rounded-xl border bg-white"> <div className="rounded-xl border bg-white">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Sub-category</TableHead> <TableHead className="w-[88px] whitespace-nowrap">ID</TableHead>
<TableHead className="hidden md:table-cell">Status</TableHead> <TableHead className="min-w-[160px]">SUB-CATEGORY</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="hidden lg:table-cell min-w-[140px]">CATEGORY</TableHead>
<TableHead className="min-w-[220px]">DESCRIPTION</TableHead>
<TableHead className="hidden xl:table-cell w-[100px] whitespace-nowrap">ORDER</TableHead>
<TableHead className="hidden md:table-cell whitespace-nowrap">CATEGORY ID</TableHead>
<TableHead className="hidden xl:table-cell min-w-[140px] whitespace-nowrap">CREATED</TableHead>
<TableHead className="whitespace-nowrap">STATUS</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedCourses.map((course) => ( {filteredSubCategories.length === 0 ? (
<TableRow <TableRow>
key={course.id} <TableCell colSpan={9} className="text-center py-12">
className="group cursor-pointer" <div className="flex flex-col items-center gap-3">
onClick={() => handleCourseClick(course.id)} <Search className="h-8 w-8 text-grayScale-200" />
> <div>
<TableCell className="max-w-md py-3.5"> <p className="text-sm font-medium text-grayScale-500">No matching sub-categories</p>
<div className="truncate text-sm font-semibold text-grayScale-700"> <p className="mt-1 text-xs text-grayScale-400">Try a different search term.</p>
{course.title}
</div> </div>
{course.description && (
<div className="mt-1 truncate text-xs text-grayScale-400">
{course.description}
</div> </div>
)}
</TableCell> </TableCell>
<TableCell className="hidden py-3.5 md:table-cell"> </TableRow>
) : (
paginatedSubCategories.map((sub) => (
<TableRow
key={sub.id}
className="group cursor-pointer"
onClick={() => handleOpenSubCategory(sub.id)}
>
<TableCell className="tabular-nums text-sm text-grayScale-500">{formatId(sub.id)}</TableCell>
<TableCell>
<div className="flex items-center gap-2.5">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<BookOpen className="h-4 w-4" />
</div>
<p className="min-w-0 text-sm font-medium text-grayScale-600">{sub.name}</p>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<span className="text-sm text-grayScale-600">{sub.category_name}</span>
</TableCell>
<TableCell>
<p className="max-w-md truncate text-sm text-grayScale-600" title={sub.description || undefined}>
{sub.description?.trim() ? sub.description : "—"}
</p>
</TableCell>
<TableCell className="hidden xl:table-cell tabular-nums text-sm text-grayScale-600">
{sub.display_order}
</TableCell>
<TableCell className="hidden md:table-cell">
<span className="text-sm tabular-nums text-grayScale-600">{formatId(sub.category_id)}</span>
</TableCell>
<TableCell className="hidden xl:table-cell text-sm text-grayScale-500">
{formatCreatedAt(sub.created_at)}
</TableCell>
<TableCell>
<Badge <Badge
variant={course.is_active ? "success" : "secondary"} variant={sub.is_active ? "success" : "secondary"}
className="text-[11px] font-semibold" className="text-[11px] font-semibold"
> >
{course.is_active ? "Active" : "Inactive"} {sub.is_active ? "Active" : "Inactive"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="py-3.5 text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-amber-400 hover:bg-amber-50 hover:text-amber-500" className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleViewRatings(course.id) handleOpenSubCategory(sub.id)
}} }}
title="View courses"
> >
<Star className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -435,7 +453,7 @@ export function CoursesPage() {
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700" className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleEditClick(course) handleEditClick(sub)
}} }}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
@ -444,13 +462,13 @@ export function CoursesPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700" className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
disabled={togglingId === course.id} disabled={togglingId === sub.id}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleToggleStatus(course) handleToggleStatus(sub)
}} }}
> >
{course.is_active ? ( {sub.is_active ? (
<ToggleLeft className="h-4 w-4" /> <ToggleLeft className="h-4 w-4" />
) : ( ) : (
<ToggleRight className="h-4 w-4" /> <ToggleRight className="h-4 w-4" />
@ -462,7 +480,7 @@ export function CoursesPage() {
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600" className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleDeleteClick(course) handleDeleteClick(sub)
}} }}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@ -470,15 +488,17 @@ export function CoursesPage() {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
{totalCount > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500"> <div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span>Showing</span> <span>Showing</span>
<span className="font-medium text-grayScale-600"> <span className="font-medium text-grayScale-600">
{startEntry}-{endEntry} {startEntry}{endEntry}
</span> </span>
<span>of</span> <span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span> <span className="font-medium text-grayScale-600">{totalCount}</span>
@ -499,11 +519,12 @@ export function CoursesPage() {
</option> </option>
))} ))}
</select> </select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" /> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button"
onClick={() => safePage > 1 && setPage(safePage - 1)} onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1} disabled={safePage === 1}
className={cn( className={cn(
@ -535,6 +556,7 @@ export function CoursesPage() {
), ),
)} )}
<button <button
type="button"
onClick={() => safePage < totalPages && setPage(safePage + 1)} onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages} disabled={safePage === totalPages}
className={cn( className={cn(
@ -546,12 +568,11 @@ export function CoursesPage() {
</button> </button>
</div> </div>
</div> </div>
) : null}
</div> </div>
)} )}
</CardContent>
</Card>
{/* Add Course Modal */} {/* Add Sub-category Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <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-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl"> <div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
@ -575,14 +596,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="course-title" htmlFor="subcat-name"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Title <span className="text-red-400">*</span> Name <span className="text-red-400">*</span>
</label> </label>
<Input <Input
id="course-title" id="subcat-name"
placeholder="Enter sub-category title" placeholder="Enter sub-category name"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
@ -590,14 +611,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="course-description" htmlFor="subcat-description"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Description <span className="text-red-400">*</span> Description
</label> </label>
<textarea <textarea
id="course-description" id="subcat-description"
placeholder="Enter sub-category description" placeholder="Optional description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={4} rows={4}
@ -605,6 +626,20 @@ export function CoursesPage() {
/> />
</div> </div>
<div>
<label htmlFor="subcat-order" className="mb-2 block text-sm font-medium text-grayScale-600">
Display order
</label>
<Input
id="subcat-order"
type="number"
min={0}
placeholder="0"
value={displayOrder}
onChange={(e) => setDisplayOrder(e.target.value)}
/>
</div>
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400"> <div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span> Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div> </div>
@ -626,8 +661,8 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Edit Course Modal */} {/* Edit Sub-category Modal */}
{showEditModal && courseToEdit && ( {showEditModal && subCategoryToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <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-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl"> <div className="mx-4 w-full max-w-md 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"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
@ -650,14 +685,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="edit-course-title" htmlFor="edit-subcat-name"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Title <span className="text-red-400">*</span> Name <span className="text-red-400">*</span>
</label> </label>
<Input <Input
id="edit-course-title" id="edit-subcat-name"
placeholder="Enter sub-category title" placeholder="Enter sub-category name"
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
/> />
@ -665,14 +700,14 @@ export function CoursesPage() {
<div> <div>
<label <label
htmlFor="edit-course-description" htmlFor="edit-subcat-description"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Description <span className="text-red-400">*</span> Description
</label> </label>
<textarea <textarea
id="edit-course-description" id="edit-subcat-description"
placeholder="Enter sub-category description" placeholder="Optional description"
value={editDescription} value={editDescription}
onChange={(e) => setEditDescription(e.target.value)} onChange={(e) => setEditDescription(e.target.value)}
rows={4} rows={4}
@ -681,29 +716,18 @@ export function CoursesPage() {
</div> </div>
<div> <div>
<label <label htmlFor="edit-subcat-order" className="mb-2 block text-sm font-medium text-grayScale-600">
htmlFor="edit-course-thumbnail" Display order
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Thumbnail
</label> </label>
<div className="space-y-2">
<FileUpload
accept="image/*"
onFileSelect={(file) => setEditThumbnailFile(file)}
label="Upload thumbnail"
description="JPEG, PNG, WEBP"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<Input <Input
id="edit-course-thumbnail" id="edit-subcat-order"
placeholder="Or paste thumbnail URL (https://...)" type="number"
value={editThumbnail} min={0}
onChange={(e) => setEditThumbnail(e.target.value)} value={editDisplayOrder}
onChange={(e) => setEditDisplayOrder(Number(e.target.value))}
/> />
</div> </div>
</div> </div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto"> <Button variant="outline" onClick={handleCloseEditModal} disabled={updating} className="w-full sm:w-auto">
@ -721,122 +745,8 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Ratings Modal */} {/* Delete Sub-category Modal */}
{showRatingsModal && ( {showDeleteModal && subCategoryToDelete && (
<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-lg 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">Sub-category Ratings</h2>
<button
onClick={() => {
setShowRatingsModal(false)
setRatingsCourseId(null)
setCourseRatings([])
}}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-6 py-6">
{courseRatingsLoading ? (
<div className="flex flex-col items-center justify-center py-16">
<SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div>
) : courseRatings.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<div className="rounded-full bg-amber-50 p-4">
<Star className="h-8 w-8 text-amber-400" />
</div>
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
<p className="mt-1 text-sm text-grayScale-400">
Ratings will appear here once learners start reviewing this sub-category.
</p>
</div>
) : (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex flex-wrap items-center gap-6 rounded-xl border border-grayScale-200 px-5 py-4">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-amber-400" fill="currentColor" />
<span className="text-lg font-bold text-grayScale-800">
{(courseRatings.reduce((sum, r) => sum + r.stars, 0) / courseRatings.length).toFixed(1)}
</span>
<span className="text-sm text-grayScale-400">/ 5</span>
</div>
<div className="h-5 w-px bg-grayScale-200" />
<span className="text-sm text-grayScale-500">
{courseRatings.length} review{courseRatings.length !== 1 ? "s" : ""}
</span>
</div>
{/* Rating cards */}
<div className="space-y-3">
{courseRatings.map((rating) => (
<div
key={rating.id}
className="rounded-xl border border-grayScale-200 bg-white p-5 space-y-3"
>
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-sm font-bold text-brand-600">
U{rating.user_id}
</div>
<div>
<p className="text-sm font-semibold text-grayScale-700">User #{rating.user_id}</p>
<p className="text-[11px] text-grayScale-400">
{new Date(rating.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{rating.updated_at !== rating.created_at && (
<span className="ml-1.5 text-grayScale-300">· edited</span>
)}
</p>
</div>
</div>
{/* Stars */}
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`h-4 w-4 ${
s <= rating.stars
? "text-amber-400"
: "text-grayScale-200"
}`}
fill={s <= rating.stars ? "currentColor" : "none"}
/>
))}
</div>
</div>
{/* Review text */}
{rating.review && (
<div className="flex gap-2">
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-300" />
<p className="text-sm leading-relaxed text-grayScale-600">
{rating.review}
</p>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Delete Course Modal */}
{showDeleteModal && courseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <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="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"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
@ -855,7 +765,7 @@ export function CoursesPage() {
</div> </div>
<p className="text-center text-sm leading-relaxed text-grayScale-600"> <p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">{courseToDelete.title}</span>? This action cannot <span className="font-semibold text-grayScale-700">{subCategoryToDelete.name}</span>? This action cannot
be undone. be undone.
</p> </p>
</div> </div>

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,5 +1,7 @@
import { Plus, ArrowRight } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Card, CardContent } from "../../components/ui/card"; import { Card, CardContent } from "../../components/ui/card";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
@ -9,33 +11,250 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogTrigger, DialogTrigger,
DialogClose, DialogFooter,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png"; import uploadIcon from "../../assets/icons/upload.png";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg";
import {
getLearningPrograms,
createLearningProgram,
updateLearningProgram,
deleteLearningProgram,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import type { LearningProgramListItem } from "../../types/course.types";
export function LearnEnglishPage() { export function LearnEnglishPage() {
const levels = [ const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
{ const [loading, setLoading] = useState(true);
id: "beginner", const [error, setError] = useState<string | null>(null);
title: "Beginner",
description: const [editingProgram, setEditingProgram] =
"Designed for learners starting from scratch. Focuses on simple grammar, and everyday communication.", useState<LearningProgramListItem | null>(null);
}, const [editName, setEditName] = useState("");
{ const [editDescription, setEditDescription] = useState("");
id: "intermediate", const [editThumbnail, setEditThumbnail] = useState("");
title: "Intermediate", const [savingEdit, setSavingEdit] = useState(false);
description: const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
"For learners who can communicate at a basic level and want to improve fluency, accuracy, and confidence.", const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
},
{ const [createOpen, setCreateOpen] = useState(false);
id: "advanced", const [createName, setCreateName] = useState("");
title: "Advanced", const [createDescription, setCreateDescription] = useState("");
description: const [createThumbnail, setCreateThumbnail] = useState("");
"Targets advanced learners aiming for professional, academic, and complex conversational English.", const [createSaving, setCreateSaving] = useState(false);
}, const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
]; const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [deletingProgram, setDeletingProgram] =
useState<LearningProgramListItem | null>(null);
const [deleting, setDeleting] = useState(false);
const openEdit = (program: LearningProgramListItem) => {
setEditingProgram(program);
setEditName(program.name ?? "");
setEditDescription(program.description?.trim() ?? "");
setEditThumbnail(program.thumbnail?.trim() ?? "");
};
const closeEdit = () => {
setEditingProgram(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
};
const handleEditThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
const maxBytes = 5 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
return;
}
setUploadingEditThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) {
throw new Error("Upload did not return a file URL");
}
setEditThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(msg);
} finally {
setUploadingEditThumbnail(false);
}
};
const clearCreateFormFields = () => {
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
}
};
const handleCreateDialogOpenChange = (open: boolean) => {
if (!open && (createSaving || createUploadingThumbnail)) return;
clearCreateFormFields();
setCreateOpen(open);
};
const handleCreateThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
const maxBytes = 5 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
return;
}
setCreateUploadingThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) {
throw new Error("Upload did not return a file URL");
}
setCreateThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(msg);
} finally {
setCreateUploadingThumbnail(false);
}
};
const handleCreateProgram = async () => {
const name = createName.trim();
if (!name) {
toast.error("Program name is required");
return;
}
setCreateSaving(true);
try {
await createLearningProgram({
name,
description: createDescription.trim(),
thumbnail: createThumbnail.trim(),
});
toast.success("Program created");
clearCreateFormFields();
setCreateOpen(false);
await fetchPrograms();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create program";
toast.error(msg);
} finally {
setCreateSaving(false);
}
};
const handleSaveEdit = async () => {
if (!editingProgram) return;
const name = editName.trim();
if (!name) {
toast.error("Program name is required");
return;
}
setSavingEdit(true);
try {
await updateLearningProgram(editingProgram.id, {
name,
description: editDescription.trim(),
thumbnail: editThumbnail.trim(),
});
toast.success("Program updated");
closeEdit();
await fetchPrograms();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update program";
toast.error(msg);
} finally {
setSavingEdit(false);
}
};
const handleConfirmDelete = async () => {
if (!deletingProgram) return;
setDeleting(true);
try {
await deleteLearningProgram(deletingProgram.id);
toast.success("Program deleted");
setDeletingProgram(null);
await fetchPrograms();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete program";
toast.error(msg);
} finally {
setDeleting(false);
}
};
const fetchPrograms = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await getLearningPrograms({ limit: 100, offset: 0 });
const raw = res.data?.data?.programs;
const list = Array.isArray(raw) ? raw : [];
const sorted = [...list].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
);
setPrograms(sorted);
} catch (e) {
console.error(e);
setError("Failed to load programs");
setPrograms([]);
toast.error("Could not load programs", {
description: "Check your connection or try again.",
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchPrograms();
}, [fetchPrograms]);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@ -46,24 +265,33 @@ export function LearnEnglishPage() {
Learn English Learn English
</h1> </h1>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1 text-sm text-grayScale-500">
Manage learning content by level Manage learning content by program cards load from the server
</p> </p>
</div> </div>
<Dialog> <Dialog open={createOpen} onOpenChange={handleCreateDialogOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="h-11 rounded-[6px] bg-brand-500 px-6 font-semibold "> <Button className="h-11 rounded-[6px] bg-brand-500 px-6 font-semibold ">
<Plus className="mr-2 h-5 w-5" /> <Plus className="mr-2 h-5 w-5" />
Add Program Add Program
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl gap-0 border-none p-0"> <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"> <DialogHeader className="p-8 pb-4">
<DialogTitle className="text-2xl font-bold text-grayScale-700"> <DialogTitle className="text-2xl font-bold text-grayScale-700">
Add New Program Add New Program
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a learning program to group courses by learner level Create a learning program via{" "}
<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> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Gradient Divider */} {/* Gradient Divider */}
@ -83,15 +311,26 @@ export function LearnEnglishPage() {
/> />
</div> </div>
</div> </div>
</div>
<form className="space-y-6 p-8 pt-4"> <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"> <div className="space-y-2">
<label className="text-[15px] text-grayScale-700"> <label className="text-[15px] text-grayScale-700">
Program Name Program Name
</label> </label>
<Input <Input
placeholder="e.g. Beginner" value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="e.g. Intermediate Track"
className="h-12 rounded-xl ring-0" className="h-12 rounded-xl ring-0"
disabled={createSaving || createUploadingThumbnail}
/> />
</div> </div>
@ -99,62 +338,90 @@ export function LearnEnglishPage() {
<label className="text-[15px] text-grayScale-700"> <label className="text-[15px] text-grayScale-700">
Description Description
</label> </label>
<Input <Textarea
placeholder="Short description explaining who this program is for" value={createDescription}
className="h-12 rounded-xl" 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>
<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"> <div className="space-y-2">
<label className="text-[15px] text-grayScale-700"> <label className="text-[15px] text-grayScale-700">
Thumbnail Thumbnail
</label> </label>
<div className="relative group cursor-pointer"> <input
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 transition-all "> 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"> <div className="mb-4">
<img <img
src={uploadIcon} src={uploadIcon}
alt="Upload icon" alt=""
className="h-10 w-10" className="h-10 w-10"
/> />
</div> </div>
<p className="text-sm"> <p className="text-sm">
<span className="font-bold text-[#9E2891]"> <span className="font-bold text-[#9E2891]">
Click to upload {createUploadingThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "} </span>{" "}
<span className="text-grayScale-500"> <span className="text-grayScale-500">
or drag and drop or paste a URL below
</span> </span>
</p> </p>
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider"> <p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
JPG, PNG (MAX 1 MB) JPG, PNG (max 5 MB)
</p> </p>
</div> </div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<img
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
className="h-12 rounded-xl"
placeholder="https://…"
disabled={createSaving || createUploadingThumbnail}
/>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
<DialogClose asChild>
<Button <Button
type="button"
variant="outline" variant="outline"
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold" className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
disabled={createSaving || createUploadingThumbnail}
onClick={() => handleCreateDialogOpenChange(false)}
> >
Cancel Cancel
</Button> </Button>
</DialogClose> <Button
<Button className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"> type="submit"
Create Program className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
disabled={createSaving || createUploadingThumbnail}
>
{createSaving ? "Creating…" : "Create Program"}
</Button> </Button>
</div> </div>
</form> </form>
@ -177,31 +444,93 @@ export function LearnEnglishPage() {
</div> </div>
</div> </div>
{/* Cards Grid */} {loading ? (
<div className="flex flex-warp gap-10"> <div className="flex flex-col items-center justify-center py-20">
{levels.map((level) => ( <img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<Card <p className="mt-3 text-sm text-grayScale-500">Loading programs</p>
key={level.title} </div>
className="group w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" ) : error ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
<img src={alertSrc} alt="" className="h-10 w-10" />
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
<Button
type="button"
variant="outline"
className="mt-4"
onClick={() => void fetchPrograms()}
> >
{/* Gradient Header */} Try again
<div </Button>
className="h-32 w-full" </div>
style={{ ) : programs.length === 0 ? (
background: <div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)", <p className="text-sm font-medium text-grayScale-600">
}} No programs yet
/> </p>
<CardContent className="bg-white p-6 flex flex-col h-[280px]"> <p className="mt-1 text-sm text-grayScale-400">
<div className="flex-1"> Add programs in the backend or use Add Program when it is connected.
<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> </p>
</div> </div>
<Link to={`/new-content/learn-english/${level.id}/courses`}> ) : (
<div className="flex flex-wrap gap-10">
{programs.map((program) => (
<Card
key={program.id}
className="group relative w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div
className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${program.name}`}
onClick={() => openEdit(program)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${program.name}`}
onClick={() => setDeletingProgram(program)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div
className="h-32 w-full bg-cover bg-center"
style={
program.thumbnail?.trim()
? {
backgroundImage: `url(${program.thumbnail.trim()})`,
}
: {
background:
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
}
}
/>
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
<div className="flex-1 min-h-0">
<h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
{program.name}
</h3>
<p className="mt-2 text-sm leading-relaxed text-grayScale-500 line-clamp-4">
{program.description?.trim()
? program.description
: "—"}
</p>
</div>
<Link
to={`/new-content/learn-english/${program.id}/courses`}
className="mt-4 block"
onClick={(e) => e.stopPropagation()}
>
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"> <Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
View Courses View Courses
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" /> <ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
@ -211,6 +540,167 @@ export function LearnEnglishPage() {
</Card> </Card>
))} ))}
</div> </div>
)}
<Dialog
open={editingProgram !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail)) return;
if (!open) closeEdit();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit program</DialogTitle>
<DialogDescription>
Update name, description, and thumbnail. Upload an image from your
computer (via file storage) or paste a URL. Changes are saved to the
server.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
</label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="rounded-xl"
placeholder="Program name"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Description
</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="rounded-xl resize-y min-h-[100px]"
placeholder="Short summary of the program"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Thumbnail
</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<Button
type="button"
variant="outline"
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
disabled={savingEdit || uploadingEditThumbnail}
onClick={() => editThumbnailFileInputRef.current?.click()}
>
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
</Button>
{editThumbnail.trim() ? (
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<img
src={editThumbnail.trim()}
alt=""
className="h-24 w-full object-cover"
/>
</div>
) : null}
</div>
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
className="rounded-xl"
placeholder="Or paste image URL (https://…)"
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Local images are sent to{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the returned URL is stored as the program thumbnail.
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={closeEdit}
disabled={savingEdit || uploadingEditThumbnail}
>
Cancel
</Button>
<Button
type="button"
className="bg-brand-500 hover:bg-brand-600"
disabled={savingEdit || uploadingEditThumbnail}
onClick={() => void handleSaveEdit()}
>
{savingEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deletingProgram && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Delete program</h2>
<button
type="button"
onClick={() => !deleting && setDeletingProgram(null)}
disabled={deleting}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">{deletingProgram.name}</span>? This action cannot be
undone. Courses under this program may be affected depending on your backend.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingProgram(null)}
disabled={deleting}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deleting}
onClick={() => void handleConfirmDelete()}
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
Video, Video,
@ -7,42 +7,39 @@ import {
Layers, Layers,
Edit2, Edit2,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
deleteTopLevelModuleLesson,
getModuleLessons,
getTopLevelCourseModules,
updateTopLevelModuleLesson,
} from "../../api/courses.api";
import type { TopLevelModuleLessonItem } from "../../types/course.types";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { VideoCard } from "./components/VideoCard"; import { VideoCard } from "./components/VideoCard";
const MOCK_VIDEOS = [ const LESSON_THUMB_GRADIENTS = [
{ "from-[#CBD5E1] to-[#94A3B8]",
id: "v1", "from-[#DBEAFE] to-[#93C5FD]",
title: "1.1 Introduction to Formal Greetings", "from-[#FEF3C7] to-[#FCD34D]",
duration: "08:45", "from-[#FCE7F3] to-[#F9A8D4]",
status: "Draft", ] as const;
thumbnailGradient: "from-[#CBD5E1] to-[#94A3B8]",
},
{
id: "v2",
title: "1.2 Understanding Email Structure",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#DBEAFE] to-[#93C5FD]",
},
{
id: "v3",
title: "1.3 Common Business Idioms",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#FEF3C7] to-[#FCD34D]",
},
{
id: "v4",
title: "1.4 Video Conference Etiquette",
duration: "08:45",
status: "Published",
thumbnailGradient: "from-[#FCE7F3] to-[#F9A8D4]",
},
];
const MOCK_PRACTICES = [ const MOCK_PRACTICES = [
{ {
@ -75,8 +72,15 @@ const MOCK_PRACTICES = [
}, },
]; ];
type ModuleDetailState = {
moduleName?: string;
moduleDescription?: string;
};
export function ModuleDetailPage() { export function ModuleDetailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const navState = location.state as ModuleDetailState | null;
const { level, courseId, moduleId } = useParams<{ const { level, courseId, moduleId } = useParams<{
level: string; level: string;
courseId: string; courseId: string;
@ -84,14 +88,211 @@ export function ModuleDetailPage() {
}>(); }>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video"); const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("Draft"); const [activeFilter, setActiveFilter] = useState("Draft");
const [videos] = useState(MOCK_VIDEOS); const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [editingLesson, setEditingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [editLessonTitle, setEditLessonTitle] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState("");
const [savingLessonEdit, setSavingLessonEdit] = useState(false);
const [thumbUploadBusy, setThumbUploadBusy] = useState(false);
const [videoUploadBusy, setVideoUploadBusy] = useState(false);
const lessonMediaUploadBusy = thumbUploadBusy || videoUploadBusy;
const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
const [practices] = useState(MOCK_PRACTICES); const [practices] = useState(MOCK_PRACTICES);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
>(null);
const [moduleListResolved, setModuleListResolved] = useState(
Boolean(navState?.moduleName?.trim()),
);
const moduleTitle = const moduleTitleFallback =
moduleId moduleId
?.split("-") ?.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ") || "Business English Fundamentals"; .join(" ") || "Module";
const displayModuleName =
navState?.moduleName?.trim() ||
loadedModuleName ||
moduleTitleFallback;
const hasNavName = Boolean(navState?.moduleName?.trim());
const displayModuleDescription = (() => {
if (hasNavName) {
return navState?.moduleDescription?.trim() || "—";
}
if (!moduleListResolved) {
return "Loading…";
}
if (loadedModuleDescription !== null) {
return loadedModuleDescription.trim() || "—";
}
return "—";
})();
useEffect(() => {
if (navState?.moduleName?.trim()) {
return;
}
const id = Number(moduleId);
const cid = Number(courseId);
if (!Number.isFinite(id) || id < 1 || !Number.isFinite(cid) || cid < 1) {
setModuleListResolved(true);
return;
}
let cancelled = false;
(async () => {
try {
const res = await getTopLevelCourseModules(cid, { limit: 100, offset: 0 });
if (cancelled) return;
const list = res.data?.data?.modules;
if (Array.isArray(list)) {
const m = list.find((mod) => mod.id === id);
if (m) {
setLoadedModuleName(m.name);
setLoadedModuleDescription(m.description ?? "");
} else {
setLoadedModuleName(null);
setLoadedModuleDescription("");
}
} else {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} catch {
if (!cancelled) {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} finally {
if (!cancelled) {
setModuleListResolved(true);
}
}
})();
return () => {
cancelled = true;
};
}, [navState?.moduleName, courseId, moduleId]);
const loadModuleLessons = useCallback(
async (options?: { showPageLoading?: boolean }) => {
const showPageLoading = options?.showPageLoading ?? true;
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
setLessons([]);
setLessonsLoadError(null);
setLessonsLoading(false);
return;
}
if (showPageLoading) {
setLessonsLoading(true);
setLessonsLoadError(null);
}
try {
const res = await getModuleLessons(mid, { limit: 100, offset: 0 });
const list = res.data?.data?.lessons;
if (Array.isArray(list)) {
setLessons(
[...list].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
),
);
} else {
setLessons([]);
}
if (showPageLoading) {
setLessonsLoadError(null);
}
} catch {
if (showPageLoading) {
setLessons([]);
setLessonsLoadError("Failed to load lessons. Please try again.");
} else {
toast.error("Failed to refresh lessons");
}
} finally {
if (showPageLoading) {
setLessonsLoading(false);
}
}
},
[moduleId],
);
useEffect(() => {
void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]);
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? "");
setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? "");
};
const closeEditLesson = () => {
if (savingLessonEdit || lessonMediaUploadBusy) return;
setEditingLesson(null);
};
const handleSaveLessonEdit = async () => {
if (!editingLesson) return;
const title = editLessonTitle.trim();
if (!title) {
toast.error("Title is required");
return;
}
setSavingLessonEdit(true);
try {
await updateTopLevelModuleLesson(editingLesson.id, {
title,
video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(),
});
toast.success("Lesson updated");
setEditingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson";
toast.error(msg);
} finally {
setSavingLessonEdit(false);
}
};
const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return;
setDeletingLessonInFlight(true);
try {
await deleteTopLevelModuleLesson(deletingLesson.id);
toast.success("Lesson deleted");
setDeletingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete lesson";
toast.error(msg);
} finally {
setDeletingLessonInFlight(false);
}
};
return ( return (
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500"> <div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
@ -110,12 +311,10 @@ export function ModuleDetailPage() {
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
<div className=""> <div className="">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight"> <h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
Module 3: {moduleTitle} {displayModuleName}
</h1> </h1>
<p className="text-grayScale-500 text-[14px] max-w-2xl"> <p className="text-grayScale-500 text-[14px] max-w-2xl">
This module covers essential vocabulary and phrases used in modern {displayModuleDescription}
business environments, including email etiquette and meeting
protocols.
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -142,7 +341,7 @@ export function ModuleDetailPage() {
<div className="h-4 w-4 flex items-center justify-center"> <div className="h-4 w-4 flex items-center justify-center">
<span className="text-xl leading-none font-light">+</span> <span className="text-xl leading-none font-light">+</span>
</div> </div>
Add Video Add Lesson
</Button> </Button>
</div> </div>
</div> </div>
@ -159,7 +358,7 @@ export function ModuleDetailPage() {
: "text-grayScale-400 hover:text-grayScale-600", : "text-grayScale-400 hover:text-grayScale-600",
)} )}
> >
Video Lesson
</button> </button>
<button <button
onClick={() => setActiveTab("practice")} onClick={() => setActiveTab("practice")}
@ -178,14 +377,27 @@ export function ModuleDetailPage() {
{/* Content */} {/* Content */}
<div className="mt-8"> <div className="mt-8">
{activeTab === "video" ? ( {activeTab === "video" ? (
videos.length > 0 ? ( lessonsLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
Loading lessons
</div>
) : lessonsLoadError ? (
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{videos.map((video) => ( {lessons.map((lesson, i) => (
<VideoCard <VideoCard
key={video.id} key={lesson.id}
{...(video as any)} id={lesson.id}
onEdit={() => console.log("Edit", video.id)} title={lesson.title}
onPublish={() => console.log("Publish", video.id)} videoUrl={lesson.video_url}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)}
/> />
))} ))}
</div> </div>
@ -197,11 +409,11 @@ export function ModuleDetailPage() {
</div> </div>
</div> </div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3"> <h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
No videos added to this module yet No lessons in this module yet
</h2> </h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed"> <p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
Videos are a great way to engage students. Start building your Lessons are a great way to engage students. Add your first
module by adding your first video lesson now. lesson to get started.
</p> </p>
<Button <Button
variant="outline" variant="outline"
@ -213,7 +425,7 @@ export function ModuleDetailPage() {
} }
> >
<Video className="h-5 w-5" /> <Video className="h-5 w-5" />
Add Video Add Lesson
</Button> </Button>
</div> </div>
) )
@ -251,6 +463,149 @@ export function ModuleDetailPage() {
</div> </div>
)} )}
</div> </div>
<Dialog
open={editingLesson !== null}
onOpenChange={(open) => {
if (!open && (savingLessonEdit || lessonMediaUploadBusy)) return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-title"
>
Title
</label>
<Input
id="edit-lesson-title"
value={editLessonTitle}
onChange={(e) => setEditLessonTitle(e.target.value)}
disabled={savingLessonEdit}
/>
</div>
<LessonMediaUploadField
kind="video"
value={editLessonVideoUrl}
onChange={setEditLessonVideoUrl}
disabled={savingLessonEdit}
onUploadBusyChange={setVideoUploadBusy}
/>
<LessonMediaUploadField
kind="thumbnail"
value={editLessonThumbnail}
onChange={setEditLessonThumbnail}
disabled={savingLessonEdit}
onUploadBusyChange={setThumbUploadBusy}
/>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-desc"
>
Description
</label>
<Textarea
id="edit-lesson-desc"
value={editLessonDescription}
onChange={(e) => setEditLessonDescription(e.target.value)}
rows={4}
disabled={savingLessonEdit}
className="min-h-[100px] resize-y"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={closeEditLesson}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void handleSaveLessonEdit()}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
{savingLessonEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deletingLesson && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">
Delete lesson
</h2>
<button
type="button"
onClick={() =>
!deletingLessonInFlight && setDeletingLesson(null)
}
disabled={deletingLessonInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{deletingLesson.title}
</span>
? This cannot be undone.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingLesson(null)}
disabled={deletingLessonInFlight}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deletingLessonInFlight}
onClick={() => void handleConfirmDeleteLesson()}
>
{deletingLessonInFlight ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -58,7 +58,13 @@ const typeColors: Record<QuestionType, string> = {
} }
export function PracticeQuestionsPage() { export function PracticeQuestionsPage() {
const { categoryId, courseId, subModuleId, practiceId } = useParams() const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
categoryId: string
courseId: string
subModuleId?: string
levelId?: string
practiceId?: string
}>()
const location = useLocation() const location = useLocation()
const [questions, setQuestions] = useState<PracticeQuestion[]>([]) const [questions, setQuestions] = useState<PracticeQuestion[]>([])
@ -102,11 +108,14 @@ export function PracticeQuestionsPage() {
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const backLink = useMemo(() => { const backLink = useMemo(() => {
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) { if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/level/") && levelId) {
return "/content/human-language"
}
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) {
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}` return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
} }
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
}, [location.pathname, categoryId, courseId, subModuleId]) }, [location.pathname, categoryId, courseId, subModuleId, levelId])
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => { const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {

View File

@ -1,5 +1,7 @@
import { ArrowLeft, Plus, FileText } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Card, CardContent } from "../../components/ui/card"; import { Card, CardContent } from "../../components/ui/card";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
@ -8,41 +10,297 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter,
DialogTrigger, DialogTrigger,
DialogClose,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png"; import uploadIcon from "../../assets/icons/upload.png";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg";
import {
createProgramCourse,
deleteTopLevelCourse,
getLearningPrograms,
getProgramCourses,
updateTopLevelCourse,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import type {
LearningProgramListItem,
ProgramCourseListItem,
} from "../../types/course.types";
export function ProgramCoursesPage() { export function ProgramCoursesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { level } = useParams<{ level: string }>(); /** Route segment is the numeric program id (see Learn English program cards). */
const { level: programIdParam } = useParams<{ level: string }>();
const programId = Number(programIdParam);
const courses = [ const [program, setProgram] = useState<LearningProgramListItem | null>(null);
{ const [courses, setCourses] = useState<ProgramCourseListItem[]>([]);
id: "a1", const [loading, setLoading] = useState(true);
title: "A1", const [error, setError] = useState<string | null>(null);
description:
"Learn basic English words, phrases, and simple sentences for daily situations.", const [deletingCourse, setDeletingCourse] = useState<ProgramCourseListItem | null>(
stats: { null,
modules: 3, );
videos: 15, const [deleting, setDeleting] = useState(false);
practices: 18,
}, const [editingCourse, setEditingCourse] = useState<ProgramCourseListItem | null>(
}, null,
{ );
id: "a2", const [editName, setEditName] = useState("");
title: "A2", const [editDescription, setEditDescription] = useState("");
description: const [editThumbnail, setEditThumbnail] = useState("");
"Build on basic skills with longer sentences, and practical conversations.", const [savingEdit, setSavingEdit] = useState(false);
stats: { const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
modules: 3, const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
videos: 15,
practices: 18, const [createCourseOpen, setCreateCourseOpen] = useState(false);
}, const [createName, setCreateName] = useState("");
}, const [createDescription, setCreateDescription] = useState("");
]; const [createThumbnail, setCreateThumbnail] = useState("");
const [createSaving, setCreateSaving] = useState(false);
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const programIdValid = Number.isFinite(programId) && programId >= 1;
const loadData = useCallback(async () => {
if (!Number.isFinite(programId) || programId < 1) {
setError("Invalid program");
setLoading(false);
setCourses([]);
setProgram(null);
return;
}
setLoading(true);
setError(null);
try {
const [coursesRes, programsRes] = await Promise.all([
getProgramCourses(programId, { limit: 100, offset: 0 }),
getLearningPrograms({ limit: 100, offset: 0 }),
]);
const programRows = programsRes.data?.data?.programs;
const list = Array.isArray(programRows) ? programRows : [];
const found = list.find((p) => p.id === programId) ?? null;
setProgram(found);
const raw = coursesRes.data?.data?.courses;
const courseList = Array.isArray(raw) ? raw : [];
const sorted = [...courseList].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
);
setCourses(sorted);
} catch (e) {
console.error(e);
setError("Failed to load courses");
setCourses([]);
setProgram(null);
toast.error("Could not load courses", {
description: "Check your connection or try again.",
});
} finally {
setLoading(false);
}
}, [programId]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleConfirmDeleteCourse = async () => {
if (!deletingCourse) return;
setDeleting(true);
try {
await deleteTopLevelCourse(deletingCourse.id);
toast.success("Course deleted");
setDeletingCourse(null);
await loadData();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete course";
toast.error(msg);
} finally {
setDeleting(false);
}
};
const openEditCourse = (course: ProgramCourseListItem) => {
setEditingCourse(course);
setEditName(course.name ?? "");
setEditDescription(course.description?.trim() ?? "");
setEditThumbnail(
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
);
};
const closeEditCourse = () => {
setEditingCourse(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) {
editThumbnailFileInputRef.current.value = "";
}
};
const handleEditCourseThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
const maxBytes = 5 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
return;
}
setUploadingEditThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) {
throw new Error("Upload did not return a file URL");
}
setEditThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(msg);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditCourse = async () => {
if (!editingCourse) return;
const name = editName.trim();
if (!name) {
toast.error("Course name is required");
return;
}
setSavingEdit(true);
try {
await updateTopLevelCourse(editingCourse.id, {
name,
description: editDescription.trim(),
thumbnail: editThumbnail.trim(),
});
toast.success("Course updated");
closeEditCourse();
await loadData();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update course";
toast.error(msg);
} finally {
setSavingEdit(false);
}
};
const clearCreateCourseForm = () => {
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
setCreateUploadingThumbnail(false);
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
}
};
const handleCreateCourseDialogOpenChange = (open: boolean) => {
if (!open && (createSaving || createUploadingThumbnail)) return;
clearCreateCourseForm();
setCreateCourseOpen(open);
};
const handleCreateCourseThumbnailFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please choose an image file");
return;
}
const maxBytes = 5 * 1024 * 1024;
if (file.size > maxBytes) {
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
return;
}
setCreateUploadingThumbnail(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) {
throw new Error("Upload did not return a file URL");
}
setCreateThumbnail(url);
toast.success("Thumbnail uploaded");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(msg);
} finally {
setCreateUploadingThumbnail(false);
}
};
const handleCreateCourse = async () => {
if (!programIdValid) return;
const name = createName.trim();
if (!name) {
toast.error("Course name is required");
return;
}
setCreateSaving(true);
try {
await createProgramCourse(programId, {
name,
description: createDescription.trim(),
thumbnail: createThumbnail.trim(),
});
toast.success("Course created");
clearCreateCourseForm();
setCreateCourseOpen(false);
await loadData();
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create course";
toast.error(msg);
} finally {
setCreateSaving(false);
}
};
const programTitle = !programIdValid
? "Program not found"
: program?.name?.trim() || `Program ${programId}`;
const programDescription =
program?.description?.trim() ||
(!loading && programIdValid && !program
? "Program details are unavailable. You can still browse courses below if they loaded."
: "");
return ( return (
<div className="space-y-8 pt-10"> <div className="space-y-8 pt-10">
@ -58,17 +316,30 @@ export function ProgramCoursesPage() {
{/* Header section */} {/* Header section */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight text-grayScale-700 capitalize"> <h1 className="text-3xl font-bold tracking-tight text-grayScale-700">
{level || "Program"} {programTitle}
</h1> </h1>
{programDescription ? (
<p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400"> <p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400">
Designed for learners starting from scratch. Focuses on simple {programDescription}
grammar, and everyday communication.
</p> </p>
) : loading ? (
<div className="flex items-center gap-2 pt-1">
<img
src={spinnerSrc}
alt=""
className="h-6 w-6 animate-spin"
/>
</div>
) : null}
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Link to={`/new-content/learn-english/${level}/courses/add-practice`}> {programIdValid ? (
<>
<Link
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
>
<Button <Button
variant="outline" variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 " className="rounded-[6px] border-brand-500 text-brand-500 "
@ -78,20 +349,35 @@ export function ProgramCoursesPage() {
</Button> </Button>
</Link> </Link>
<Dialog> <Dialog
open={createCourseOpen}
onOpenChange={handleCreateCourseDialogOpenChange}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"> <Button
type="button"
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
>
<Plus className="mr-2 h-5 w-5" /> <Plus className="mr-2 h-5 w-5" />
Add Courses Add Courses
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl gap-0 border-none p-0"> <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"> <DialogHeader className="p-8 pb-4">
<DialogTitle className="text-2xl font-bold text-grayScale-700"> <DialogTitle className="text-2xl font-bold text-grayScale-700">
Add New Course Add New Course
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a CEFR-aligned course inside this program. Create a course via{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /programs/:program_id/courses
</code>
. Thumbnail can be a URL or a file from{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -112,80 +398,128 @@ export function ProgramCoursesPage() {
/> />
</div> </div>
</div> </div>
</div>
<form className="space-y-6 p-8 pt-4"> <form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
e.preventDefault();
void handleCreateCourse();
}}
>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700"> <label className="text-[15px] font-medium text-grayScale-700">
Course Name Course Name
</label> </label>
<Input placeholder="e.g. A1" className="h-12 rounded-xl" /> <Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="e.g. Introduction to German A1"
className="h-12 rounded-xl"
disabled={createSaving || createUploadingThumbnail}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700"> <label className="text-[15px] font-medium text-grayScale-700">
Description Description
</label> </label>
<Input <Textarea
placeholder="Brief overview of what learners will achieve in this course" value={createDescription}
className="h-12 rounded-xl" onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Short summary of the course"
rows={3}
className="min-h-[88px] resize-y rounded-xl"
disabled={createSaving || createUploadingThumbnail}
/> />
</div> </div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Course 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"> <div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700"> <label className="text-[15px] font-medium text-grayScale-700">
Thumbnail Thumbnail
</label> </label>
<div className="relative group cursor-pointer"> <input
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 transition-all"> ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
disabled={createSaving || createUploadingThumbnail}
/>
<button
type="button"
className="relative w-full cursor-pointer rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-left transition-all hover:border-[#9E289180] disabled:cursor-not-allowed disabled:opacity-60"
disabled={createSaving || createUploadingThumbnail}
onClick={() =>
createThumbnailFileInputRef.current?.click()
}
>
<div className="flex flex-col items-center justify-center">
<div className="mb-4"> <div className="mb-4">
<img <img
src={uploadIcon} src={uploadIcon}
alt="Upload icon" alt=""
className="h-10 w-10" className="h-10 w-10"
/> />
</div> </div>
<p className="text-sm"> <p className="text-sm">
<span className="font-bold text-[#9E2891]"> <span className="font-bold text-[#9E2891]">
Click to upload {createUploadingThumbnail
? "Uploading…"
: "Click to upload"}
</span>{" "} </span>{" "}
<span className="text-grayScale-500"> <span className="text-grayScale-500">
or drag and drop or paste a URL below
</span> </span>
</p> </p>
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider"> <p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
JPG, PNG (MAX 1 MB) JPG, PNG (max 5 MB)
</p> </p>
</div> </div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<img
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
className="h-12 rounded-xl"
placeholder="https://…"
disabled={createSaving || createUploadingThumbnail}
/>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
<DialogClose asChild>
<Button <Button
type="button"
variant="outline" variant="outline"
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold" className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
disabled={createSaving || createUploadingThumbnail}
onClick={() => handleCreateCourseDialogOpenChange(false)}
> >
Cancel Cancel
</Button> </Button>
</DialogClose> <Button
<Button className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600"> type="submit"
Create Course className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600"
disabled={createSaving || createUploadingThumbnail}
>
{createSaving ? "Creating…" : "Create Course"}
</Button> </Button>
</div> </div>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
) : null}
</div> </div>
</div> </div>
@ -204,52 +538,115 @@ export function ProgramCoursesPage() {
</div> </div>
</div> </div>
{/* Cards Grid */} {loading ? (
<div className="flex flex-warp gap-10 "> <div className="flex flex-col items-center justify-center py-20">
{courses.map((course) => ( <img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
</div>
) : error && courses.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
<img src={alertSrc} alt="" className="h-10 w-10" />
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
<Button
type="button"
variant="outline"
className="mt-4"
onClick={() => void loadData()}
>
Try again
</Button>
</div>
) : courses.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No courses in this program yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Add courses using the button above when the flow is connected to the
API.
</p>
</div>
) : (
<div className="flex flex-wrap gap-10">
{courses.map((course) => {
const modules =
course.module_count ?? course.modules_count ?? 0;
const lessons = course.lesson_count ?? course.videos_count ?? 0;
const practices =
course.practice_count ?? course.practices_count ?? 0;
const thumbnailSrc =
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "";
return (
<Card <Card
key={course.id} key={course.id}
className="group w-[290px] overflow-hidden border border-grayScale-100 shadow-soft transition-all duration-300 hover:shadow-lg" className="group relative w-[290px] overflow-hidden border border-grayScale-100 shadow-soft transition-all duration-300 hover:shadow-lg"
> >
{/* Gradient Header */}
<div <div
className="h-32 w-full" 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"
style={{ >
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${course.name}`}
onClick={() => openEditCourse(course)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${course.name}`}
onClick={() => setDeletingCourse(course)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div
className="h-32 w-full bg-cover bg-center"
style={
thumbnailSrc
? {
backgroundImage: `url(${thumbnailSrc})`,
}
: {
background: background:
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)", "linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
}} }
}
/> />
<CardContent className="p-6"> <CardContent className="p-6">
<h3 className="text-xl font-bold text-grayScale-700"> <h3 className="text-xl font-bold text-grayScale-700">
{course.title} {course.name}
</h3> </h3>
<p className="mt-2 text-[13px] leading-relaxed text-grayScale-500 line-clamp-2"> <p className="mt-2 text-[13px] leading-relaxed text-grayScale-500 line-clamp-2">
{course.description} {course.description?.trim() ? course.description : "—"}
</p> </p>
{/* Stats */}
<div className="my-6 grid grid-cols-3 gap-4 border-y border-grayScale-50 py-4"> <div className="my-6 grid grid-cols-3 gap-4 border-y border-grayScale-50 py-4">
<div className="text-center"> <div className="text-center">
<p className="text-base font-bold text-grayScale-700"> <p className="text-base font-bold text-grayScale-700">
{course.stats.modules} {modules}
</p> </p>
<p className="text-[10px] font-medium text-grayScale-400 uppercase tracking-wider"> <p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
Modules Modules
</p> </p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-base font-bold text-grayScale-700"> <p className="text-base font-bold text-grayScale-700">
{course.stats.videos} {lessons}
</p> </p>
<p className="text-[10px] font-medium text-grayScale-400 uppercase tracking-wider"> <p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
Videos Lessons
</p> </p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-base font-bold text-grayScale-700"> <p className="text-base font-bold text-grayScale-700">
{course.stats.practices} {practices}
</p> </p>
<p className="text-[10px] font-medium text-grayScale-400 uppercase tracking-wider"> <p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
Practices Practices
</p> </p>
</div> </div>
@ -261,7 +658,7 @@ export function ProgramCoursesPage() {
className="h-10 flex-1 rounded-[6px] border-brand-500 text-[13px] font-semibold text-brand-500 " className="h-10 flex-1 rounded-[6px] border-brand-500 text-[13px] font-semibold text-brand-500 "
onClick={() => onClick={() =>
navigate( navigate(
`/new-content/learn-english/${level}/courses/${course.title.toLowerCase()}`, `/new-content/learn-english/${programIdParam}/courses/${course.id}`,
) )
} }
> >
@ -273,8 +670,168 @@ export function ProgramCoursesPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} );
})}
</div> </div>
)}
<Dialog
open={editingCourse !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail)) return;
if (!open) closeEditCourse();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit course</DialogTitle>
<DialogDescription>
Update name, description, and thumbnail. Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
</label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="rounded-xl"
placeholder="Course name"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Description
</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[100px] resize-y rounded-xl"
placeholder="Short summary"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Thumbnail
</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditCourseThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<Button
type="button"
variant="outline"
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
disabled={savingEdit || uploadingEditThumbnail}
onClick={() => editThumbnailFileInputRef.current?.click()}
>
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
</Button>
{editThumbnail.trim() ? (
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<img
src={editThumbnail.trim()}
alt=""
className="h-24 w-full object-cover"
/>
</div>
) : null}
</div>
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
className="rounded-xl"
placeholder="Or paste image URL (https://…)"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={closeEditCourse}
disabled={savingEdit || uploadingEditThumbnail}
>
Cancel
</Button>
<Button
type="button"
className="bg-brand-500 hover:bg-brand-600"
disabled={savingEdit || uploadingEditThumbnail}
onClick={() => void handleSaveEditCourse()}
>
{savingEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deletingCourse && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Delete course</h2>
<button
type="button"
onClick={() => !deleting && setDeletingCourse(null)}
disabled={deleting}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{deletingCourse.name}
</span>
? This cannot be undone. Related modules and content may be
affected depending on your backend.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingCourse(null)}
disabled={deleting}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deleting}
onClick={() => void handleConfirmDeleteCourse()}
>
{deleting ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { X } from "lucide-react"; import { useEffect, useState, type FormEvent } from "react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Dialog, Dialog,
@ -9,28 +9,106 @@ import {
DialogClose, DialogClose,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Select } from "../../../components/ui/select"; import { Textarea } from "../../../components/ui/textarea";
import uploadIcon from "../../../assets/icons/upload.png"; import { toast } from "sonner";
import { createTopLevelCourseModule } from "../../../api/courses.api";
import { ModuleIconUploadField } from "./ModuleIconUploadField";
interface AddModuleModalProps { interface AddModuleModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
courseId: number;
onCreated?: () => void | Promise<void>;
} }
export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) { export function AddModuleModal({
isOpen,
onClose,
courseId,
onCreated,
}: AddModuleModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [submitting, setSubmitting] = useState(false);
const [iconUploadBusy, setIconUploadBusy] = useState(false);
useEffect(() => {
if (isOpen) {
setName("");
setDescription("");
setIcon("");
setSubmitting(false);
setIconUploadBusy(false);
}
}, [isOpen]);
const resetAndClose = () => {
setName("");
setDescription("");
setIcon("");
setIconUploadBusy(false);
onClose();
};
const handleOpenChange = (open: boolean) => {
if (!open && (submitting || iconUploadBusy)) return;
if (!open) {
resetAndClose();
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("Module name is required");
return;
}
if (!Number.isFinite(courseId) || courseId < 1) {
toast.error("Invalid course");
return;
}
setSubmitting(true);
try {
await createTopLevelCourseModule(courseId, {
name: trimmedName,
description: description.trim(),
icon: icon.trim(),
});
toast.success("Module created");
if (onCreated) {
await onCreated();
}
resetAndClose();
} catch (err: unknown) {
console.error(err);
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create module";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl gap-0 border-none p-0 overflow-hidden rounded-[16px] shadow-2xl"> <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">
<DialogHeader className="p-8 pb-4 relative"> <div className="flex-shrink-0">
<DialogHeader className="relative p-8 pb-4">
<DialogTitle className="text-2xl font-bold text-grayScale-700"> <DialogTitle className="text-2xl font-bold text-grayScale-700">
Add New Module Add New Module
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a module to organize videos and practices. Create a module with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /courses/:courseId/modules
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Gradient Divider */}
<div className="relative"> <div className="relative">
<div <div
className="absolute inset-0 flex items-center" className="absolute inset-0 flex items-center"
@ -45,15 +123,23 @@ export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
/> />
</div> </div>
</div> </div>
</div>
<form className="space-y-6 p-8 pt-4"> <form
className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain p-8 pt-4"
onSubmit={(e) => void handleSubmit(e)}
>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700"> <label className="text-[15px] font-medium text-grayScale-700">
Module Title Module title
</label> </label>
<Input <Input
placeholder="e.g. Daily Introductions" value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Greetings & Introductions"
className="h-12 rounded-xl" className="h-12 rounded-xl"
disabled={submitting}
required
/> />
</div> </div>
@ -61,63 +147,40 @@ export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
<label className="text-[15px] font-medium text-grayScale-700"> <label className="text-[15px] font-medium text-grayScale-700">
Description Description
</label> </label>
<Input <Textarea
placeholder="Short description of this module" value={description}
className="h-12 rounded-xl" onChange={(e) => setDescription(e.target.value)}
placeholder="Learn to introduce yourself and talk about your life."
className="min-h-[88px] resize-y rounded-xl"
disabled={submitting}
rows={3}
/> />
</div> </div>
<div className="space-y-2"> <ModuleIconUploadField
<label className="text-[15px] font-medium text-grayScale-700"> value={icon}
Module Order onChange={setIcon}
</label> disabled={submitting}
<Select className="h-12 rounded-xl"> onUploadBusyChange={setIconUploadBusy}
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Icon
</label>
<div className="relative group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/> />
</div>
<p className="text-sm">
<span className="font-bold text-[#9E2891]">
Click to upload
</span>{" "}
<span className="text-grayScale-500">or drag and drop</span>
</p>
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
JPG, PNG (MAX 1 MB)
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
<DialogClose asChild> <DialogClose asChild>
<Button <Button
type="button"
variant="outline" variant="outline"
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold" className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
disabled={submitting || iconUploadBusy}
> >
Cancel Cancel
</Button> </Button>
</DialogClose> </DialogClose>
<Button <Button
type="submit" type="submit"
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600 text-white shadow-lg shadow-brand-500/20" className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold text-white shadow-lg shadow-brand-500/20 hover:bg-brand-600"
disabled={submitting || iconUploadBusy}
> >
Create Module {submitting ? "Creating…" : "Create module"}
</Button> </Button>
</div> </div>
</form> </form>

View File

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

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

View File

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

View File

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

View File

@ -1,14 +1,43 @@
import { MoreVertical, Edit2, Play } from "lucide-react"; import { useEffect, useMemo, useState } from "react";
import { MoreVertical, Edit2, Play, Pencil, Trash2 } from "lucide-react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { isAdminOrSuperAdminRole } from "../../../lib/sessionRole";
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
getVideoPreview,
} from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
interface VideoCardProps { interface VideoCardProps {
id: string; id?: string | number;
title: string; title: string;
duration: string; /** Omits the duration chip when not provided (e.g. API has no length yet). */
status: "Draft" | "Published"; duration?: string;
thumbnailGradient: string; /** When omitted, shows a neutral "Lesson" chip and no Publish button. */
status?: "Draft" | "Published";
thumbnailGradient?: string;
thumbnailUrl?: string | null;
/**
* When set, the hover play control opens a preview (Vimeo, YouTube, or direct
* video file) in a dialog.
*/
videoUrl?: string;
/**
* When true, shows edit/delete in the top-right of the thumbnail (same
* hover pattern as module cards) and removes the footer + overflow menu.
*/
hoverModuleActions?: boolean;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void;
onPublish?: () => void; onPublish?: () => void;
} }
@ -16,38 +45,270 @@ export function VideoCard({
title, title,
duration, duration,
status, status,
thumbnailGradient, thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
thumbnailUrl,
videoUrl,
onEdit, onEdit,
onDelete,
onPublish, onPublish,
hoverModuleActions = false,
}: VideoCardProps) { }: VideoCardProps) {
const [thumbFailed, setThumbFailed] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
/** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false);
const [iframeSessionKey, setIframeSessionKey] = useState(0);
const useGradient = !thumbnailUrl?.trim() || thumbFailed;
const videoPreview = useMemo(
() => (videoUrl?.trim() ? getVideoPreview(videoUrl) : { kind: "none" as const }),
[videoUrl],
);
const limitedEmbedSrc = useMemo(() => {
if (videoPreview.kind !== "iframe") return null;
return applyShortPreviewToEmbedUrl(
videoPreview.src,
videoPreview.label,
DEFAULT_PREVIEW_MAX_SECONDS,
);
}, [videoPreview]);
const canPreview = Boolean(videoUrl?.trim());
const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS,
);
useEffect(() => {
if (!previewOpen) {
setIframeSessionDone(false);
return;
}
if (videoPreview.kind !== "iframe" || !limitedEmbedSrc) {
return;
}
if (iframeSessionDone) {
return;
}
const ms = DEFAULT_PREVIEW_MAX_SECONDS * 1000;
const id = window.setTimeout(() => {
setIframeSessionDone(true);
}, ms);
return () => window.clearTimeout(id);
}, [
previewOpen,
videoPreview.kind,
limitedEmbedSrc,
iframeSessionDone,
]);
const handlePreviewOpenChange = (open: boolean) => {
setPreviewOpen(open);
if (!open) {
setIframeSessionDone(false);
setIframeSessionKey((k) => k + 1);
}
};
return ( return (
<div className="group bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col"> <div
className={cn(
"group relative bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col",
)}
>
{/* Thumbnail */} {/* Thumbnail */}
<div <div
className={cn( className={cn(
"relative h-44 w-full bg-gradient-to-br", "relative h-44 w-full overflow-hidden",
thumbnailGradient, useGradient && "bg-gradient-to-br",
useGradient && thumbnailGradient,
!useGradient && "bg-grayScale-100",
)} )}
> >
{hoverModuleActions && (onEdit || onDelete) ? (
<div
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
>
{onEdit ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${title}`}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${title}`}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
) : null}
{!useGradient && thumbnailUrl ? (
<img
src={thumbnailUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbFailed(true)}
/>
) : null}
{/* Duration Badge */} {/* Duration Badge */}
<div className="absolute bottom-3 right-3 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm"> {duration ? (
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
{duration} {duration}
</div> </div>
{/* Play Overlay */} ) : null}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10"> {/* 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"> <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" /> <Play className="h-6 w-6 text-white fill-current" />
</div> </div>
</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 */} {/* Content */}
<div className="p-5 space-y-4 flex-1 flex flex-col"> <div className="p-5 space-y-4 flex-1 flex flex-col">
<div className="flex items-center justify-between">
{/* Status Badge */}
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border", "flex items-center gap-2",
hoverModuleActions ? "justify-start" : "justify-between",
)}
>
{/* Status Badge */}
{status ? (
<div
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0",
status === "Published" status === "Published"
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]" ? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]", : "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
@ -55,23 +316,34 @@ export function VideoCard({
> >
<div <div
className={cn( className={cn(
"h-1.5 w-1.5 rounded-full", "h-1.5 w-1.5 rounded-full flex-shrink-0",
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]", status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
)} )}
/> />
{status} {status}
</div> </div>
{/* Menu */} ) : (
<button className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"> <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" /> <MoreVertical className="h-5 w-5" />
</button> </button>
) : null}
</div> </div>
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug"> <h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
{title} {title}
</h3> </h3>
{/* Actions */} {/* Actions (footer) — not used for API lesson cards with hover tools */}
{!hoverModuleActions ? (
<div className="pt-2 space-y-3 mt-auto"> <div className="pt-2 space-y-3 mt-auto">
<Button <Button
variant="outline" variant="outline"
@ -81,6 +353,7 @@ export function VideoCard({
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
Edit Edit
</Button> </Button>
{status ? (
<Button <Button
disabled={status === "Published"} disabled={status === "Published"}
onClick={onPublish} onClick={onPublish}
@ -93,7 +366,9 @@ export function VideoCard({
> >
{status === "Published" ? "Published" : "Publish"} {status === "Published" ? "Published" : "Publish"}
</Button> </Button>
) : null}
</div> </div>
) : null}
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Bell, Loader2, Mail, MailOpen, Megaphone } from "lucide-react" import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getTeamMembers } from "../../api/team.api" import { getTeamMembers } from "../../api/team.api"
import type { TeamMember } from "../../types/team.types" import type { TeamMember } from "../../types/team.types"
@ -282,7 +283,7 @@ export function CreateNotificationPage() {
> >
{sending ? ( {sending ? (
<> <>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
Sending Sending
</> </>
) : ( ) : (
@ -347,7 +348,7 @@ export function CreateNotificationPage() {
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2"> <div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && ( {recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400"> <div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <SpinnerIcon className="mr-2 h-4 w-4" alt="" />
Loading users Loading users
</div> </div>
)} )}

View File

@ -1,8 +1,18 @@
import { useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import {
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight, Plus,
AlertCircle, Eye, X, Pencil, Check, Search,
Shield,
ShieldCheck,
ChevronLeft,
ChevronRight,
AlertCircle,
Eye,
X,
Pencil,
Check,
Trash2,
} from "lucide-react" } from "lucide-react"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
@ -12,7 +22,14 @@ import { Textarea } from "../../components/ui/textarea"
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "../../components/ui/dialog" } from "../../components/ui/dialog"
import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api" import {
getRoles,
getRoleDetail,
getAllPermissions,
setRolePermissions,
updateRole,
deleteRole,
} from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types" import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
@ -36,6 +53,11 @@ export function RolesListPage() {
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false) const [detailLoading, setDetailLoading] = useState(false)
// Delete modal state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// Role info editing state // Role info editing state
const [editingRole, setEditingRole] = useState(false) const [editingRole, setEditingRole] = useState(false)
const [editName, setEditName] = useState("") const [editName, setEditName] = useState("")
@ -59,9 +81,7 @@ export function RolesListPage() {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [query]) }, [query])
// Fetch roles const fetchRoles = useCallback(async () => {
useEffect(() => {
const fetchRoles = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@ -77,10 +97,13 @@ export function RolesListPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}
fetchRoles()
}, [debouncedQuery, page, pageSize]) }, [debouncedQuery, page, pageSize])
// Fetch roles
useEffect(() => {
fetchRoles()
}, [fetchRoles])
// Open role detail // Open role detail
const handleViewRole = async (roleId: number) => { const handleViewRole = async (roleId: number) => {
setDetailOpen(true) setDetailOpen(true)
@ -97,6 +120,45 @@ export function RolesListPage() {
} }
} }
const handleDeleteRoleClick = (role: Role) => {
setRoleToDelete(role)
setDeleteDialogOpen(true)
}
const handleCancelDeleteRole = () => {
setDeleteDialogOpen(false)
setRoleToDelete(null)
}
const handleConfirmDeleteRole = async () => {
if (!roleToDelete) return
setDeleteLoading(true)
try {
const res = await deleteRole(roleToDelete.id)
toast.success(res.data.message ?? "Role deleted successfully")
// Close dialogs if the deleted role is currently opened.
if (selectedRole?.id === roleToDelete.id) {
setDetailOpen(false)
setSelectedRole(null)
setEditingPermissions(false)
setEditingRole(false)
setPermSearch("")
}
setRoleToDelete(null)
setDeleteDialogOpen(false)
await fetchRoles()
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Failed to delete role."
toast.error(message)
} finally {
setDeleteLoading(false)
}
}
// Enter role info edit mode // Enter role info edit mode
const handleEditRole = () => { const handleEditRole = () => {
if (!selectedRole) return if (!selectedRole) return
@ -302,7 +364,7 @@ export function RolesListPage() {
{roles.map((role) => ( {roles.map((role) => (
<Card <Card
key={role.id} key={role.id}
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md" className="overflow-hidden border border-grayScale-100 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
> >
<div <div
className={cn( className={cn(
@ -312,7 +374,7 @@ export function RolesListPage() {
: "bg-gradient-to-r from-brand-500 to-brand-600", : "bg-gradient-to-r from-brand-500 to-brand-600",
)} )}
/> />
<CardContent className="p-5"> <CardContent className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div <div
@ -330,23 +392,53 @@ export function RolesListPage() {
)} )}
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3> <h3 className="text-sm font-semibold uppercase tracking-wide text-grayScale-700">
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1"> {role.name}
{role.description} </h3>
<p className="mt-0.5 text-xs text-grayScale-500 line-clamp-2">
{role.description?.trim() || "No description provided for this role."}
</p> </p>
</div> </div>
</div> </div>
{role.is_system && ( <Badge
<Badge variant="warning" className="shrink-0 text-[10px]"> variant={role.is_system ? "warning" : "outline"}
System className="shrink-0 text-[10px]"
>
{role.is_system ? "System" : "Custom"}
</Badge> </Badge>
)}
</div> </div>
<div className="mt-4 flex items-center justify-between"> <div className="grid grid-cols-2 gap-2 rounded-xl border border-grayScale-100 bg-grayScale-50/70 p-2.5 text-[11px]">
<div>
<p className="text-grayScale-400">Role ID</p>
<p className="font-semibold text-grayScale-700">#{role.id}</p>
</div>
<div>
<p className="text-grayScale-400">Created</p>
<p className="font-semibold text-grayScale-700">
{new Date(role.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-grayScale-400"> <span className="text-[11px] text-grayScale-400">
Created {new Date(role.created_at).toLocaleDateString()} Open details to view permissions
</span> </span>
<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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -357,6 +449,7 @@ export function RolesListPage() {
View View
</Button> </Button>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@ -689,6 +782,55 @@ export function RolesListPage() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete role dialog */}
<Dialog
open={deleteDialogOpen}
onOpenChange={(open) => {
setDeleteDialogOpen(open)
if (!open) handleCancelDeleteRole()
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="h-5 w-5" />
Delete Role
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this role? This action cannot be undone.
</DialogDescription>
</DialogHeader>
{roleToDelete && (
<div className="rounded-lg bg-red-50 border border-red-100 p-3">
<p className="text-sm font-medium text-red-700">{roleToDelete.name}</p>
<p className="text-xs text-red-500 mt-0.5">Role #{roleToDelete.id}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelDeleteRole}
disabled={deleteLoading}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1.5"
disabled={deleteLoading || !roleToDelete}
onClick={handleConfirmDeleteRole}
>
{deleteLoading ? <SpinnerIcon className="h-3.5 w-3.5" /> : <Trash2 className="h-3.5 w-3.5" />}
{deleteLoading ? "Deleting..." : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@ -45,6 +45,7 @@ export interface GetCoursesResponse {
export interface CreateCourseRequest { export interface CreateCourseRequest {
category_id: number category_id: number
sub_category_id?: number | null
title: string title: string
description: string description: string
} }
@ -56,6 +57,268 @@ export interface UpdateCourseRequest {
is_active?: boolean is_active?: boolean
} }
/** Row from GET /programs (e.g. Beginner / Intermediate program buckets) */
export interface LearningProgramListItem {
id: number
name: string
description?: string | null
thumbnail?: string | null
sort_order: number
created_at: string
}
export interface UpdateLearningProgramRequest {
name: string
description: string
thumbnail: string
}
export interface CreateLearningProgramRequest {
name: string
description: string
thumbnail: string
}
export interface CreateLearningProgramResponse {
message: string
data: LearningProgramListItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetLearningProgramsResponse {
message: string
data: {
programs: LearningProgramListItem[]
total_count: number
limit?: number
offset?: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /programs/:program_id/courses */
export interface ProgramCourseListItem {
id: number
program_id: number
name: string
description: string
sort_order: number
created_at: string
thumbnail?: string | null
/** Some list endpoints may expose the image as `thumbnail_url` instead. */
thumbnail_url?: string | null
/** GET /programs/:id/courses aggregates. */
module_count?: number
lesson_count?: number
practice_count?: number
/** Legacy aggregate field names; prefer module_count, lesson_count, practice_count. */
modules_count?: number
videos_count?: number
practices_count?: number
}
/** Body for PUT /courses/:id (program-linked Learn English courses). */
export interface UpdateTopLevelCourseRequest {
name: string
description: string
thumbnail: string
}
/** Body for POST /programs/:program_id/courses */
export interface CreateProgramCourseRequest {
name: string
description: string
thumbnail: string
}
export interface CreateProgramCourseResponse {
message: string
data: ProgramCourseListItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetProgramCoursesResponse {
message: string
data: {
total_count: number
limit: number
offset: number
courses: ProgramCourseListItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
/** Row from GET /courses/:courseId/modules (Learn English track). */
export interface TopLevelCourseModuleItem {
id: number
program_id: number
course_id: number
name: string
description: string
icon?: string | null
sort_order: number
created_at: string
}
export interface GetTopLevelCourseModulesResponse {
message: string
data: {
limit: number
offset: number
modules: TopLevelCourseModuleItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /modules/:id (Learn English top-level modules). */
export interface UpdateTopLevelCourseModuleRequest {
name: string
description: string
icon: string
}
/** Body for POST /courses/:courseId/modules */
export interface CreateTopLevelCourseModuleRequest {
name: string
description: string
icon: string
}
export interface CreateTopLevelCourseModuleResponse {
message: string
data: TopLevelCourseModuleItem
success: boolean
status_code: number
metadata: unknown | null
}
/** Row from GET /modules/:moduleId/lessons (Learn English top-level module lessons). */
export interface TopLevelModuleLessonItem {
id: number
module_id: number
title: string
video_url: string
thumbnail: string
description: string
sort_order: number
created_at: string
}
export interface GetTopLevelModuleLessonsResponse {
message: string
data: {
total_count: number
limit: number
offset: number
lessons: TopLevelModuleLessonItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
/** Practice returned by GET /courses|modules|lessons/.../practices (Learn English parent-linked practice). */
export interface ParentContextPractice {
id: number
parent_kind: string
parent_id: number
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number | null
created_at: string
}
export interface GetPracticesByParentContextResponse {
message: string
data: {
offset: number
limit: number
practices: ParentContextPractice[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown | null
}
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
export interface CreateParentLinkedPracticeRequest {
parent_kind: PracticeParentKind
parent_id: number
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number
}
export interface CreateParentLinkedPracticeResponse {
message: string
data: ParentContextPractice
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
export interface UpdateParentLinkedPracticeRequest {
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
persona_id?: number | null
}
export interface UpdateParentLinkedPracticeResponse {
message: string
data: ParentContextPractice
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
export interface UpdateTopLevelModuleLessonRequest {
title: string
video_url: string
thumbnail: string
description: string
}
/** Body for POST /modules/:moduleId/lessons. */
export interface CreateTopLevelModuleLessonRequest {
title: string
video_url: string
thumbnail: string
description: string
}
export interface CreateTopLevelModuleLessonResponse {
message: string
data: TopLevelModuleLessonItem
success: boolean
status_code: number
metadata: unknown | null
}
// ============================================ // ============================================
// Legacy Types (deprecated - using SubCourse hierarchy now) // Legacy Types (deprecated - using SubCourse hierarchy now)
// Keeping for backward compatibility with existing API endpoints // Keeping for backward compatibility with existing API endpoints
@ -172,7 +435,13 @@ export interface GetModulesResponse {
export interface CreateModuleRequest { export interface CreateModuleRequest {
level_id: number level_id: number
title: string title: string
content: string /** Legacy field kept for backward compatibility. */
content?: string
/** Preferred field for module detail text. */
description?: string
icon_url?: string
display_order?: number
is_active?: boolean
} }
/** @deprecated Use UpdateSubCourseRequest instead */ /** @deprecated Use UpdateSubCourseRequest instead */
@ -192,6 +461,8 @@ export interface UpdateModuleStatusRequest {
export interface SubCourse { export interface SubCourse {
id: number id: number
course_id: number course_id: number
/** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
level_id?: number
module_id?: number module_id?: number
title: string title: string
description: string description: string
@ -701,6 +972,72 @@ export interface HumanLanguageLesson {
practices: LearningPathPractice[] practices: LearningPathPractice[]
} }
export interface SubModuleLessonDetail {
id: number
sub_module_id: number
display_order: number
is_active: boolean
created_at: string
title: string
description?: string | null
thumbnail?: string | null
teaching_text?: string | null
teaching_image_url?: string | null
teaching_audio_url?: string | null
teaching_video_url?: string | null
}
export interface SubModuleLesson {
id: number
sub_module_id: number
display_order: number
is_active: boolean
created_at: string
title: string
description?: string | null
thumbnail?: string | null
teaching_text?: string | null
teaching_image_url?: string | null
teaching_audio_url?: string | null
teaching_video_url?: string | null
}
export interface GetSubModuleLessonDetailResponse {
message: string
data: SubModuleLessonDetail
success: boolean
status_code: number
metadata: unknown
}
export interface UpdateSubModuleLessonRequest {
title: string
description?: string | null
thumbnail?: string | null
teaching_text?: string | null
teaching_image_url?: string | null
teaching_audio_url?: string | null
teaching_video_url?: string | null
display_order: number
is_active: boolean
}
export interface UpdateSubModuleLessonResponse {
message: string
data: SubModuleLessonDetail
success: boolean
status_code: number
metadata: unknown
}
export interface GetSubModuleLessonsResponse {
message: string
data: SubModuleLesson[]
success: boolean
status_code: number
metadata: unknown
}
export interface GetHumanLanguageLessonsResponse { export interface GetHumanLanguageLessonsResponse {
message: string message: string
data: { data: {
@ -714,10 +1051,209 @@ export interface GetHumanLanguageLessonsResponse {
metadata: unknown metadata: unknown
} }
/** Row from GET /course-management/human-language/sub-categories */
export interface HumanLanguageSubCategoryListItem {
id: number
category_id: number
category_name: string
name: string
description?: string | null
display_order: number
is_active: boolean
created_at: string
/** Present on some payloads; ignore if unused. */
total_count?: number
}
export interface GetHumanLanguageSubCategoriesResponse {
message: string
data: {
sub_categories: HumanLanguageSubCategoryListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/categories/:categoryId/sub-categories */
export interface CategorySubCategoryListItem {
id: number
category_id: number
category_name: string
name: string
description?: string | null
display_order: number
is_active: boolean
created_at: string
/** Sometimes echoed per row by the API; safe to ignore. */
total_count?: number
}
export interface GetCategorySubCategoriesResponse {
message: string
data: {
sub_categories: CategorySubCategoryListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/sub-categories/:subCategoryId/courses */
export interface SubCategoryCourseListItem {
id: number
category_id: number
sub_category_id: number
title: string
description?: string | null
thumbnail?: string | null
intro_video_url?: string | null
is_active: boolean
total_count?: number
}
export interface GetSubCategoryCoursesResponse {
message: string
data: {
courses: SubCategoryCourseListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/courses/:courseId/levels or GET /course-management/levels */
export interface CourseLevelRow {
id: number
course_id: number
cefr_level: string
display_order: number
is_active: boolean
created_at: string
title: string
description?: string | null
thumbnail?: string | null
total_count?: number
}
export interface GetCourseLevelsForCourseResponse {
message: string
data: {
levels: CourseLevelRow[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
export interface GetCourseLevelsAllResponse {
message: string
data: {
levels: CourseLevelRow[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
export interface GetCourseLevelByIdResponse {
message: string
data: CourseLevelRow
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/modules/:moduleId/sub-modules */
export interface CourseSubModuleListItem {
id: number
module_id: number
title: string
description?: string | null
display_order: number
is_active: boolean
created_at: string
legacy_sub_course_id?: number | null
thumbnail?: string | null
tips?: string | null
total_count?: number
}
export interface GetSubModulesByModuleResponse {
message: string
data: {
sub_modules: CourseSubModuleListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/human-language/hierarchy */
export interface HumanLanguageHierarchyFlatRow {
category_id: number
category_name: string
sub_category_id?: number | null
sub_category_name?: string | null
course_id?: number | null
course_title?: string | null
}
export interface GetHumanLanguageHierarchyFlatResponse {
message: string
data: HumanLanguageHierarchyFlatRow[]
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/courses/:courseId/hierarchy */
export interface CourseHierarchyRow {
course_id: number
course_title: string
level_id?: number | null
cefr_level?: string | null
level_title?: string | null
level_description?: string | null
level_thumbnail?: string | null
module_id?: number | null
module_title?: string | null
module_icon_url?: string | null
sub_module_id?: number | null
sub_module_title?: string | null
sub_module_description?: string | null
sub_module_thumbnail?: string | null
sub_module_tips?: string | null
sub_module_display_order?: number | null
}
export interface GetCourseHierarchyResponse {
message: string
data: CourseHierarchyRow[]
success: boolean
status_code: number
metadata: unknown
}
export interface HumanLanguageSubModule { export interface HumanLanguageSubModule {
id: number id: number
title: string title: string
videos: LearningPathVideo[] videos: LearningPathVideo[]
lessons?: {
id: number
question_set_id: number
title: string
status: string
question_count: number
display_order: number
intro_video_url?: string | null
}[]
practices: LearningPathPractice[] practices: LearningPathPractice[]
} }
@ -728,6 +1264,7 @@ export interface HumanLanguageModule {
} }
export interface HumanLanguageLevelTree { export interface HumanLanguageLevelTree {
level_id?: number
level: string level: string
modules: HumanLanguageModule[] modules: HumanLanguageModule[]
} }

View File

@ -60,6 +60,14 @@ export interface CreateRoleResponse {
metadata: unknown metadata: unknown
} }
export interface DeleteRoleResponse {
message: string
success: boolean
status_code: number
// Some backends may include extra fields; keep it optional for compatibility.
metadata?: unknown
}
export interface SetRolePermissionsRequest { export interface SetRolePermissionsRequest {
permission_ids: number[] permission_ids: number[]
} }