Compare commits

...

65 Commits

Author SHA1 Message Date
a9216c4f4b Merge branch 'el-ui' into main (prefer el-ui on conflicts) 2026-05-02 01:11:25 -07:00
af4f713395 integartion of refresh URL 2026-04-27 05:58:39 -07:00
472e71d1a2 more static files access 2026-04-27 05:14:36 -07:00
89597dbc87 more static files access fix 2026-04-27 04:54:52 -07:00
599c5dd239 static files access fix 2026-04-27 04:49:40 -07:00
7308d9bbcd ui 2026-04-27 09:52:30 +03:00
b4ab66b4a6 lesson integration 2026-04-25 02:48:52 -07:00
3634d2eb79 program+course+module integrations 2026-04-24 08:27:39 -07:00
c4ebbd903d Merge branch 'el-ui' into main (prefer el-ui on conflicts) 2026-04-24 06:06:41 -07:00
e239b28678 Merge branch 'el-ui' of https://gitea.yaltopia.com/Yimaru/Yimaru-Admin into el-ui
Made-with: Cursor

# Conflicts:
#	src/api/courses.api.ts
#	src/app/AppRoutes.tsx
#	src/pages/content-management/AddNewPracticePage.tsx
2026-04-24 06:06:32 -07:00
dc07ab72d2 Merge branch 'el-ui' into main (prefer el-ui on conflicts) 2026-04-24 05:51:37 -07:00
d4d61bfed2 course 2026-04-24 15:20:51 +03:00
1480eefbe6 yes 2026-04-22 17:10:43 +03:00
73f11ea1a0 Content admin: course hierarchy, sub-categories API, and stability fixes
- Sub-categories: load from GET categories/:id/sub-categories; SubCategoryCoursesPage
- Course structure: levels/modules/sub-modules APIs; SubCoursesPage hierarchy browser
- Sub-module detail: HumanLanguageSubModulePage for category routes; resolveSubModuleForCourse
- Types and courses API: module sub-modules endpoint, hierarchy array guard
- Misc: AppLayout/IssuesPage fixes, CoursesPage refactor, Human Language hierarchy page

Made-with: Cursor
2026-04-20 10:30:28 -07:00
f6344c19f9 Fallback edit lesson detail when missing
If /sub-module-lessons/:id returns not found, load the edit form from the linked question set instead.

Made-with: Cursor
2026-04-16 06:31:27 -07:00
5ee897cfad Add lesson detail fallback for new lessons
Handle environments where sub-module lesson detail is missing by falling back to question-set detail, and keep lesson form/status typing safe.

Made-with: Cursor
2026-04-16 06:25:09 -07:00
3ad0f0a63d Hide Step 3 hints for lesson questions
Made-with: Cursor
2026-04-16 05:21:22 -07:00
71ba71476a edit lesson form UI adjustment 2026-04-16 05:16:30 -07:00
5a2c36e276 Refresh lesson card after edit
Made-with: Cursor
2026-04-16 05:07:09 -07:00
beb0e32dde Fix auth header on edit lesson cache bust
Made-with: Cursor
2026-04-16 05:00:04 -07:00
3607e4491b Prevent stale edit lesson form
Made-with: Cursor
2026-04-16 04:54:33 -07:00
b5946b9da3 Fix stale edit-lesson form after save
Made-with: Cursor
2026-04-16 04:44:32 -07:00
7c3f2192ef Fix edit-lesson question editing UI
Embed lesson question editor inside the 'Edit lesson' dialog, remove extra inline lesson question panel, and fix lesson card question counts.

Made-with: Cursor
2026-04-16 04:29:54 -07:00
bebab7ba1e Add lesson question bank editing
For lessons, fetch related questions by lesson question_set_id and render a styled question bank panel (same UX as practice), including add/edit/delete with proper refresh.

Made-with: Cursor
2026-04-16 04:03:12 -07:00
78e1e2e0ef fix stale lesson detail counts in human language page
Fetch selected lesson details from the sub-module lesson endpoint so the inline detail panel shows the latest question count and lesson metadata.

Made-with: Cursor
2026-04-16 03:11:42 -07:00
dc9b7f9d12 use lesson detail endpoint for human language edits
Load human language lesson edit data from the dedicated sub-module lesson detail API so lesson metadata stays aligned with the backend response shape.

Made-with: Cursor
2026-04-16 02:59:45 -07:00
bf76f729aa Fix role list refresh after deletion
Made-with: Cursor
2026-04-16 01:34:04 -07:00
28af7994f8 Integrate role deletion response shape
Ensures the role delete API returns the standardized `{ message, success, status_code }` success payload.

Made-with: Cursor
2026-04-16 01:26:58 -07:00
23d100cdde fix sticky filter stacking under global topbar
Raise topbar z-index and lower the Human Language filters panel stack so sticky filters cannot render above top navigation controls.

Made-with: Cursor
2026-04-15 04:42:17 -07:00
97a65ae01e fix human language filters overlap with topbar
Move the sticky filters panel below the admin topbar and lower its stacking order so it no longer renders above global navigation.

Made-with: Cursor
2026-04-15 04:37:02 -07:00
df8dae047f improve human language scroll restore on refresh
Retry scroll restoration until the page is tall enough so refreshes restore to the saved position smoothly, while keeping reduced-motion fallback.

Made-with: Cursor
2026-04-15 04:34:34 -07:00
1f0046a8ee standardize loading indicators with shared spinner asset
Replace ad-hoc Loader2 loading indicators with SpinnerIcon so loading states across content and notifications pages use the same Circular-indeterminate progress indicator.

Made-with: Cursor
2026-04-15 04:30:07 -07:00
d33bacf628 fix human language navigation and practice fetch guards
Route published lesson success back to the Human Language page so prior scroll position is restored, and explicitly guard practice question rendering by fetch status.

Made-with: Cursor
2026-04-15 04:11:29 -07:00
814a6a54e8 stabilize flows course selection by strict category match
Filter course options by exact selected category_id in flows to prevent first-load course mismatch that changed module counts after refresh.

Made-with: Cursor
2026-04-14 10:13:59 -07:00
5b1d3903e0 align flows tab data sources with unified sub-module hierarchy
Switch flows detail loading from legacy learning-path assumptions to unified course hierarchy APIs (sub-modules, sub-module videos, and SUB_MODULE-owned practice sets).

Made-with: Cursor
2026-04-14 09:55:28 -07:00
416b18794c fix lesson review to count default question card
Show default Question 1 in step-3 review count/list even before text is filled, so publish review matches the active question cards.

Made-with: Cursor
2026-04-14 09:31:25 -07:00
5059e6db14 add background retries while human language tab is active
Keep retrying hierarchy fetch for a short period when the tab is active and still empty, so data appears automatically without manual refresh.

Made-with: Cursor
2026-04-14 09:13:49 -07:00
967339a400 load human language data on tab route activation
Trigger hierarchy fetch whenever the Human Language route is selected so data appears immediately when switching tabs from Content Management.

Made-with: Cursor
2026-04-14 09:10:00 -07:00
60a29816fb harden human language initial load with staged retries
Run multiple delayed hierarchy retries on first tab navigation to prevent empty-state flicker and avoid requiring manual page refresh.

Made-with: Cursor
2026-04-14 09:06:26 -07:00
a8e4ef76e9 fix human language tab empty state on first navigation
Retry hierarchy loading once when initial tab navigation returns empty, so content appears immediately without manual page refresh.

Made-with: Cursor
2026-04-14 09:03:01 -07:00
a006aa5c85 add route-aware scroll restoration across app pages
Persist and restore per-route scroll positions in the main layout container so page position is remembered after refresh and navigation.

Made-with: Cursor
2026-04-14 08:58:14 -07:00
981780536e style lesson review/success pages and stabilize first-load content fetch
Match lesson review and success screens to the practice-style UI and add a single retry for initial hierarchy fetches so content management data appears immediately after login.

Made-with: Cursor
2026-04-14 08:54:40 -07:00
700080f001 fix duplicate subcategory error on quick path recreate
Make human-language quick path creation idempotent by reusing existing subcategories/courses when names already exist, avoiding unique-constraint failures.

Made-with: Cursor
2026-04-14 08:45:45 -07:00
ea73323fce fix lesson intro video flow and polish review layout
Auto-process intro video URLs on blur with preview support, improve local upload reliability, and refine Step 1 + Review styling for the lesson creation wizard.

Made-with: Cursor
2026-04-14 08:06:48 -07:00
558cf11abc fix lesson stepper visibility and state styling
Restore a stable practice-style step indicator in Add New Lesson so active and completed steps are clearly visible and connectors render correctly.

Made-with: Cursor
2026-04-14 08:00:26 -07:00
a3f31e92c1 refine lesson creation form styling and soften lesson tab contrast
Restyle the lesson creation flow to match the polished practice experience with cleaner step visuals and section layouts, and reduce high-contrast accents in the lessons panel for a calmer UI.

Made-with: Cursor
2026-04-14 07:56:37 -07:00
eee5771957 add bulk lesson delete and preserve scroll position
Enable selecting and deleting multiple lessons at once in the human language panel, and persist page scroll so users return to their previous position after reload.

Made-with: Cursor
2026-04-14 07:48:36 -07:00
fe3f235fcd add lesson edit/delete controls and soften lesson card contrast
Introduce lesson edit and delete actions in the human language list using question set updates/deletes, and refine lesson card/status styling for lower visual contrast with richer detail presentation.

Made-with: Cursor
2026-04-14 07:42:36 -07:00
997043fac9 show created lessons in human language list with richer cards
Load sub-module QUIZ question sets into lesson rows so newly created lessons appear immediately, and upgrade lesson cards/detail panel styling to show status, question counts, and intro video previews.

Made-with: Cursor
2026-04-14 07:36:25 -07:00
8c2971f217 clarify intro video URL import button label
Replace technical API wording in the lesson intro video action with user-friendly text while keeping the same /files/upload behavior.

Made-with: Cursor
2026-04-14 07:27:50 -07:00
38c5c16824 align lesson wizard UI with lesson schema flow
Remove practice-specific fields from the lesson page and keep the flow focused on question_sets + sub_module_lessons while preserving intro video upload/import and preview behavior.

Made-with: Cursor
2026-04-14 07:25:07 -07:00
0cc2e4ce4e add files-upload URL import and intro video preview for lessons
Update the lesson wizard intro video section to support both file upload and general URL import via /files/upload, with Vimeo-safe normalization and inline video preview.

Made-with: Cursor
2026-04-14 07:08:38 -07:00
5ddfed8d28 add full-page lesson creation wizard flow
Introduce a practice-style Add New Lesson page routed from human language sub-modules, wire it to sub_module_lessons-compatible save logic, and remove the temporary lesson modal path.

Made-with: Cursor
2026-04-14 07:04:02 -07:00
177d10de15 replace lesson quick-create with structured lesson form
Add a Create Lesson dialog in the human language panel so lesson creation mirrors the practice UX while targeting sub_module_lessons data requirements.

Made-with: Cursor
2026-04-14 06:50:25 -07:00
6df388fb98 switch lesson creation to sub_module_lessons flow
Create lessons via question-set + sub-module-lesson attachment instead of video-only handling, and update new-level creation to reuse existing CEFR levels when present.

Made-with: Cursor
2026-04-14 06:39:39 -07:00
06a0daedfe refine role management cards with richer metadata
Improve role card visual hierarchy and add clearer metadata (role type, id, created date, description preview) so the list feels less sparse and more informative.

Made-with: Cursor
2026-04-14 06:27:30 -07:00
e5d1ba9b8d add category delete UI and streamline module creation loading
Add deletion controls for course categories and avoid full-page loading overlay during module/sub-module creation so button-level spinners remain the only loading indicator.

Made-with: Cursor
2026-04-14 06:22:36 -07:00
51ac1ad81d add new lesson quick action in human language panel
Show a New lesson button in the lessons tab alongside the practice action so lesson creation is directly accessible from each sub-module panel.

Made-with: Cursor
2026-04-14 06:11:37 -07:00
fe3f254dfc fix level/module removal behavior and reduce creation popups
Use module delete operations for level/module removal so removed levels disappear correctly, and suppress success toasts for module/sub-module creation to keep the flow cleaner and faster.

Made-with: Cursor
2026-04-14 05:58:51 -07:00
909a2f42bc fix second module creation in human language paths
Create additional modules/sub-modules directly under existing level and module IDs instead of re-running the lesson bootstrap flow, which prevented adding a second module.

Made-with: Cursor
2026-04-14 05:51:55 -07:00
bfbdf0fc19 add sub-category and course delete controls in human language page
Wire delete actions and confirmation dialogs for selected sub-categories and courses, backed by the new sub-category delete API route.

Made-with: Cursor
2026-04-14 05:45:35 -07:00
78111f161f fix category and course loading with duplicate names
Normalize duplicate category names in hierarchy responses and aggregate courses across same-name category IDs so Human Language and Courses tabs consistently show data.

Made-with: Cursor
2026-04-14 05:36:54 -07:00
da6754e6f5 fix human language category selection when duplicates exist
Prefer the most populated Human Language category (then latest id fallback) so sub-categories and courses render correctly when multiple same-name categories are present.

Made-with: Cursor
2026-04-14 05:31:57 -07:00
5206fb2e1a fix human language quick-create path creation
Create a real sub-category under Human Language and pass sub_category_id when creating the course so the new path renders immediately in the hierarchy panel.

Made-with: Cursor
2026-04-14 05:23:44 -07:00
24b5a0d7ee fix human language hierarchy rendering after create
Normalize flat hierarchy rows from backend into the nested shape expected by the content-management page so new sub-categories and courses show immediately.

Made-with: Cursor
2026-04-14 05:11:34 -07:00
78 changed files with 18766 additions and 6334 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.

10
package-lock.json generated
View File

@ -23,6 +23,7 @@
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-is": "^19.2.5",
"react-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
@ -5315,11 +5316,10 @@
}
},
"node_modules/react-is": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
"license": "MIT",
"peer": true
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",

View File

@ -25,6 +25,7 @@
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-is": "^19.2.5",
"react-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"sonner": "^2.0.7",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,52 +1,67 @@
import { Navigate, Route, Routes } from "react-router-dom"
import { AppLayout } from "../layouts/AppLayout"
import { DashboardPage } from "../pages/DashboardPage"
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
import { CoursesPage } from "../pages/content-management/CoursesPage"
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage"
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
import { NotFoundPage } from "../pages/NotFoundPage"
import { NotificationsPage } from "../pages/notifications/NotificationsPage"
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage"
import { UserDetailPage } from "../pages/user-management/UserDetailPage"
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
import { UsersListPage } from "../pages/user-management/UsersListPage"
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard"
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage"
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage"
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout"
import { RolesListPage } from "../pages/role-management/RolesListPage"
import { AddRolePage } from "../pages/role-management/AddRolePage"
import { PracticeDetailsPage } from "../pages/content-management/PracticeDetailsPage"
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage"
import { SettingsPage } from "../pages/SettingsPage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
import { LoginPage } from "../pages/auth/LoginPage"
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
import { VerificationPage } from "../pages/auth/VerificationPage"
import { AboutPage } from "../pages/AboutPage"
import { TermsPage } from "../pages/TermsPage"
import { PrivacyPage } from "../pages/PrivacyPage"
import { AccountDeletionPage } from "../pages/AccountDeletionPage"
import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "../layouts/AppLayout";
import { DashboardPage } from "../pages/DashboardPage";
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
import { CoursesPage } from "../pages/content-management/CoursesPage";
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage";
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage";
import { SubModulesPage } from "../pages/content-management/SubCoursesPage";
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage";
import { SpeakingPage } from "../pages/content-management/SpeakingPage";
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
import { NewContentPage } from "../pages/content-management/NewContentPage";
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow";
import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow";
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
import { UnitManagementPage } from "../pages/content-management/UnitManagementPage";
import { QuestionTypeLibraryPage } from "../pages/content-management/QuestionTypeLibraryPage";
import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuestionTypeFlow";
import { NotFoundPage } from "../pages/NotFoundPage";
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
import { UsersListPage } from "../pages/user-management/UsersListPage";
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
import { RolesListPage } from "../pages/role-management/RolesListPage";
import { AddRolePage } from "../pages/role-management/AddRolePage";
import { PracticeDetailsPage } from "../pages/content-management/PracticeDetailsPage";
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
import { QuestionsPage } from "../pages/content-management/QuestionsPage";
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage";
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
import { UserLogPage } from "../pages/user-log/UserLogPage";
import { IssuesPage } from "../pages/issues/IssuesPage";
import { ProfilePage } from "../pages/ProfilePage";
import { SettingsPage } from "../pages/SettingsPage";
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage";
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage";
import { LoginPage } from "../pages/auth/LoginPage";
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
import { VerificationPage } from "../pages/auth/VerificationPage";
import { AboutPage } from "../pages/AboutPage";
import { TermsPage } from "../pages/TermsPage";
import { PrivacyPage } from "../pages/PrivacyPage";
import { AccountDeletionPage } from "../pages/AccountDeletionPage";
export function AppRoutes() {
return (
@ -75,10 +90,10 @@ export function AppRoutes() {
</Route>
<Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<CourseCategoryPage />} />
<Route index element={<Navigate to="practices" replace />} />
<Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguagePage />} />
<Route path="human-language" element={<HumanLanguageHierarchyPage />} />
<Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
element={<AddNewPracticePage />}
@ -91,19 +106,52 @@ export function AppRoutes() {
path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
element={<HumanLanguageSubModulePage />}
/>
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
<Route
path="category/:categoryId"
element={<ContentOverviewPage />}
/>
<Route
path="category/:categoryId/courses"
element={<CoursesPage />}
/>
{/* Course → Sub-module → Lesson/Practice */}
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
<Route
path="category/:categoryId/courses/:courseId/sub-modules"
element={<SubModulesPage />}
/>
<Route
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId"
element={<SubModuleContentPage />}
/>
<Route
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice"
element={<AddNewPracticePage />}
/>
<Route
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />}
/>
{/* Legacy aliases */}
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<SubModuleContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
<Route
path="category/:categoryId/courses/:courseId/sub-courses"
element={<SubModulesPage />}
/>
<Route
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId"
element={<SubModuleContentPage />}
/>
<Route
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice"
element={<AddNewPracticePage />}
/>
<Route
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />}
/>
<Route
path="category/:categoryId/courses/add-video"
element={<AddVideoPage />}
/>
<Route path="speaking" element={<SpeakingPage />} />
<Route path="speaking/add-practice" element={<AddPracticePage />} />
<Route path="practices" element={<PracticeDetailsPage />} />
@ -113,8 +161,73 @@ export function AppRoutes() {
<Route path="questions/edit/:id" element={<AddQuestionPage />} />
</Route>
<Route path="/new-content" element={<NewContentPage />} />
<Route
path="/new-content/courses"
element={<ProgramTypeSelectionPage />}
/>
<Route
path="/new-content/question-types"
element={<QuestionTypeLibraryPage />}
/>
<Route
path="/new-content/question-types/create"
element={<CreateQuestionTypeFlow />}
/>
<Route
path="/new-content/courses/:programType"
element={<ProgramDetailPage />}
/>
<Route
path="/new-content/courses/:programType/attach-practice"
element={<AttachProgramPracticeFlow />}
/>
<Route
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice"
element={<AttachPracticeFlow />}
/>
<Route
path="/new-content/courses/:programType/:courseId"
element={<CourseManagementPage />}
/>
<Route
path="/new-content/courses/:programType/:courseId/:unitId"
element={<UnitManagementPage />}
/>
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
element={<CourseModuleDetailPage />}
/>
<Route
path="/new-content/learn-english"
element={<LearnEnglishPage />}
/>
<Route
path="/new-content/learn-english/:level/courses"
element={<ProgramCoursesPage />}
/>
<Route
path="/new-content/learn-english/:level/courses/:courseId"
element={<CourseDetailPage />}
/>
<Route
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId"
element={<ModuleDetailPage />}
/>
<Route
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
element={<AddVideoFlow />}
/>
<Route
path="/new-content/learn-english/:level/courses/add-practice"
element={<AddPracticeFlow />}
/>
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/notifications/create" element={<CreateNotificationPage />} />
<Route
path="/notifications/create"
element={<CreateNotificationPage />}
/>
<Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
@ -128,7 +241,5 @@ export function AppRoutes() {
<Route path="*" element={<NotFoundPage />} />
</Routes>
)
);
}

BIN
src/assets/icons/upload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -13,57 +13,65 @@ import {
Users,
Users2,
X,
} from "lucide-react"
import { type ComponentType, useEffect, useState } from "react"
import { NavLink } from "react-router-dom"
import { cn } from "../../lib/utils"
import { BrandLogo } from "../brand/BrandLogo"
import { getUnreadCount } from "../../api/notifications.api"
} from "lucide-react";
import { type ComponentType, useEffect, useState } from "react";
import { NavLink } from "react-router-dom";
import { cn } from "../../lib/utils";
import { BrandLogo } from "../brand/BrandLogo";
import { getUnreadCount } from "../../api/notifications.api";
type NavItem = {
label: string
to: string
icon: ComponentType<{ className?: string }>
}
label: string;
to: string;
icon: ComponentType<{ className?: string }>;
};
const navItems: NavItem[] = [
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
{ label: "User Management", to: "/users", icon: Users },
{ label: "Role Management", to: "/roles", icon: Shield },
{ label: "Content Management", to: "/content", icon: BookOpen },
{ label: "New Content", to: "/new-content", icon: BookOpen },
{ label: "Notifications", to: "/notifications", icon: Bell },
{ label: "User Log", to: "/user-log", icon: ClipboardList },
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
{ label: "Team Management", to: "/team", icon: Users2 },
{ label: "Profile", to: "/profile", icon: UserCircle2 },
]
];
type SidebarProps = {
isOpen: boolean
isCollapsed: boolean
onToggleCollapse: () => void
onClose: () => void
}
isOpen: boolean;
isCollapsed: boolean;
onToggleCollapse: () => void;
onClose: () => void;
};
export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) {
const [unreadCount, setUnreadCount] = useState(0)
export function Sidebar({
isOpen,
isCollapsed,
onToggleCollapse,
onClose,
}: SidebarProps) {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const fetchUnread = async () => {
try {
const res = await getUnreadCount()
setUnreadCount(res.data.unread)
const res = await getUnreadCount();
setUnreadCount(res.data.unread);
} catch {
// silently fail
}
}
};
fetchUnread()
fetchUnread();
window.addEventListener("notifications-updated", fetchUnread)
return () => window.removeEventListener("notifications-updated", fetchUnread)
}, [])
window.addEventListener("notifications-updated", fetchUnread);
return () =>
window.removeEventListener("notifications-updated", fetchUnread);
}, []);
return (
<>
@ -86,7 +94,12 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
isOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className={cn("flex items-center justify-between px-2", isCollapsed && "justify-center")}>
<div
className={cn(
"flex items-center justify-between px-2",
isCollapsed && "justify-center",
)}
>
{isCollapsed ? (
<span className="h-10 w-10 overflow-hidden">
<BrandLogo className="h-10 w-auto max-w-none" />
@ -103,7 +116,11 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
onClick={onToggleCollapse}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? <ChevronRight className="h-5 w-5" /> : <ChevronLeft className="h-5 w-5" />}
{isCollapsed ? (
<ChevronRight className="h-5 w-5" />
) : (
<ChevronLeft className="h-5 w-5" />
)}
</button>
<button
type="button"
@ -117,7 +134,7 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const Icon = item.icon
const Icon = item.icon;
return (
<NavLink
key={item.to}
@ -143,25 +160,36 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
)}
>
<Icon className="h-4 w-4" />
{isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
{isCollapsed &&
item.to === "/notifications" &&
unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
</span>
{!isCollapsed && <span className="truncate">{item.label}</span>}
{!isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
{!isCollapsed && (
<span className="truncate">{item.label}</span>
)}
{!isCollapsed && item.to !== "/notifications" && isActive ? (
{!isCollapsed &&
item.to === "/notifications" &&
unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{!isCollapsed &&
item.to !== "/notifications" &&
isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : !isCollapsed && item.to === "/notifications" && unreadCount === 0 && isActive ? (
) : !isCollapsed &&
item.to === "/notifications" &&
unreadCount === 0 &&
isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : null}
</>
)}
</NavLink>
)
);
})}
</nav>
@ -169,8 +197,8 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
<button
type="button"
onClick={() => {
localStorage.clear()
window.location.href = "/login"
localStorage.clear();
window.location.href = "/login";
}}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
@ -184,5 +212,5 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
</div>
</aside>
</>
)
);
}

View File

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

View File

@ -1,12 +1,12 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "../../lib/utils"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -20,8 +20,8 @@ const DialogOverlay = React.forwardRef<
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@ -38,27 +38,42 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<DialogPrimitive.Close className="absolute right-6 top-10 rounded-sm opacity-60 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-6 w-6" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@ -66,11 +81,14 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@ -81,8 +99,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
@ -95,5 +113,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};

View File

@ -1,21 +1,21 @@
import * as React from "react"
import { cn } from "../../lib/utils"
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = "Input"
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-[6px] border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";

View File

@ -1,6 +1,6 @@
import * as React from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "../../lib/utils"
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "../../lib/utils";
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
@ -18,10 +18,9 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-600" />
</div>
)
);
},
)
Select.displayName = "Select"
);
Select.displayName = "Select";

View File

@ -1,61 +1,55 @@
import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "../../lib/utils"
import { cn } from "../../lib/utils";
export interface StepperProps {
steps: string[]
currentStep: number
className?: string
steps: string[];
currentStep: number;
className?: string;
}
export function Stepper({ steps, currentStep, className }: StepperProps) {
return (
<div className={cn("flex w-full items-center", className)}>
<div className={cn("flex w-full items-start justify-between", className)}>
{steps.map((step, index) => {
const stepNumber = index + 1
const isCompleted = stepNumber < currentStep
const isCurrent = stepNumber === currentStep
const stepNumber = index + 1;
const isCurrent = stepNumber === currentStep;
return (
<React.Fragment key={step}>
<div className="flex items-center">
<div className="flex flex-col items-center">
<div
className={cn(
"grid h-10 w-10 place-items-center rounded-full border-2 text-sm font-semibold transition-colors",
isCompleted && "border-brand-500 bg-brand-500 text-white",
// Active step should be visually prominent.
isCurrent && "border-brand-500 bg-brand-500 text-white",
!isCompleted && !isCurrent && "border-grayScale-300 bg-white text-grayScale-400",
)}
>
{isCompleted ? <Check className="h-5 w-5" /> : stepNumber}
</div>
<span
className={cn(
"mt-2 text-xs font-medium",
isCurrent && "text-brand-600",
!isCurrent && "text-grayScale-500",
)}
>
{step}
</span>
</div>
</div>
<div
key={step}
className="flex-1 relative flex flex-col items-center group"
>
{/* Connector Line - floats between circles with gap on both sides */}
{index < steps.length - 1 && (
<div
className={cn(
// Keep the connector visually continuous with the step circles.
"mx-2 h-0.5 flex-1",
// Color the track up to the current step.
isCompleted || isCurrent ? "bg-brand-500" : "bg-grayScale-200",
)}
className="absolute top-4 h-[1.5px] bg-grayScale-200 z-0"
style={{ left: "calc(50% + 50px)", right: "calc(-50% + 50px)" }}
/>
)}
</React.Fragment>
)
{/* Circle */}
<div
className={cn(
"relative z-10 grid h-8 w-8 place-items-center rounded-full border-2 text-sm font-bold transition-all duration-300 mb-3",
isCurrent
? "border-brand-500 bg-brand-500 text-white shadow-md scale-110"
: "border-grayScale-100 bg-white text-grayScale-400 font-medium",
)}
>
{stepNumber}
</div>
{/* Label */}
<span
className={cn(
"relative z-10 text-[12px] font-bold transition-colors duration-300",
isCurrent ? "text-brand-500" : "text-grayScale-400 font-medium",
)}
>
{step}
</span>
</div>
);
})}
</div>
)
);
}

View File

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

13
src/lib/sessionRole.ts Normal file
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 token = localStorage.getItem("access_token");
if (token) {
return <Navigate to="/dashboard" replace />;
}
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
@ -162,6 +159,10 @@ export function LoginPage() {
}
}, [googleReady, handleGoogleCallback]);
if (token) {
return <Navigate to="/dashboard" replace />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

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>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,268 @@
import { useState } from "react";
import {
Link,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import successIcon from "../../assets/success.svg";
import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
import { ReviewStep } from "./components/practice-steps/ReviewStep";
export function AddPracticeFlow() {
const navigate = useNavigate();
const { level } = useParams<{ level: string }>();
const [searchParams] = useSearchParams();
const backTo = searchParams.get("backTo");
const courseId = searchParams.get("courseId");
const moduleId = searchParams.get("moduleId");
const isModuleContext = backTo === "module";
const isCourseContext = backTo === "modules";
const backLabel =
backTo === "module"
? "Back to Module"
: backTo === "modules"
? "Back to Modules"
: "Back to Courses";
const backPath =
backTo === "module" && courseId && moduleId
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`
: backTo === "modules" && courseId
? `/new-content/learn-english/${level}/courses/${courseId}`
: `/new-content/learn-english/${level}/courses`;
const flowSteps = isModuleContext
? ["Context", "Persona", "Questions", "Review"]
: ["Context", "Scenario", "Persona", "Questions", "Review"];
const [currentStep, setCurrentStep] = useState(1);
const [selectedPersona, setSelectedPersona] = useState<string | null>(
"dawit",
);
const [isPublished, setIsPublished] = useState(false);
const [formData, setFormData] = useState({
program: "Intermediate",
course: "A2",
title: "",
description: "",
selectedVideo: "",
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
questions: [
{
id: "q1",
text: "How long have you been studying English?",
type: "Speaking",
voicePrompt: "prompt_q1_en.mp3",
sampleAnswer: "prompt_q1_en.mp3",
},
],
});
const nextStep = () =>
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (isPublished) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500">
<div className="mb-10 relative">
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
<img
src={successIcon}
alt="Success"
className="h-[128px] w-[128px] relative"
/>
</div>
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
Practice Published Successfully!
</h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
Your speaking practice is now active and available inside the module.
</p>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
<Button
onClick={() => navigate(backPath)}
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
>
Go back to Module
</Button>
<Button
onClick={() => {
setIsPublished(false);
setCurrentStep(1);
setFormData({
...formData,
title: "",
description: "",
});
}}
variant="outline"
className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white "
>
Add Another Practice
</Button>
</div>
</div>
);
}
// Helper to map currentStep to the actual component for the module flow
const renderStep = () => {
if (!isModuleContext) {
switch (currentStep) {
case 1:
return (
<ContextStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
isCourseContext={isCourseContext}
/>
);
case 2:
return (
<ScenarioStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<PersonaStep
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 4:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 5:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
prevStep={prevStep}
setIsPublished={setIsPublished}
isModuleContext={isModuleContext}
/>
);
default:
return null;
}
} else {
// Module Context Flow (Skips Scenario)
switch (currentStep) {
case 1:
return (
<ContextStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
isCourseContext={isCourseContext}
/>
);
case 2:
return (
<PersonaStep
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 4:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
prevStep={prevStep}
setIsPublished={setIsPublished}
isModuleContext={isModuleContext}
/>
);
default:
return null;
}
}
};
return (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
{/* Header */}
<div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8">
<Link
to={backPath}
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 decoration-none"
>
<ArrowLeft className="h-4 w-4" />
{backLabel}
</Link>
</div>
<div className=" mb-10">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-[#0F172A]">
Add New Practice
</h1>
<Button
variant="outline"
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
onClick={() => navigate(backPath)}
>
Cancel
</Button>
</div>
<p className="text-grayScale-400 text-base">
Create a new immersive practice session for students.
</p>
</div>
<div className="mx-auto w-[70%] mb-12">
<Stepper steps={flowSteps} currentStep={currentStep} />
</div>
<div
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
>
{renderStep()}
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -0,0 +1,193 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Clock, FileVideo, Check } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import successIcon from "../../assets/success.svg";
import { AttachPracticeStep1 } from "./components/practice-steps/AttachPracticeStep1";
import { AttachPracticeReviewStep } from "./components/practice-steps/AttachPracticeReviewStep";
export function AttachPracticeFlow() {
const navigate = useNavigate();
const { programType, courseId, unitId, moduleId } = useParams<{
programType: string;
courseId: string;
unitId: string;
moduleId: string;
}>();
const backPath = `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
const [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false);
const [formData, setFormData] = useState({
program:
programType === "skill"
? "Skill-Based Courses"
: "English Proficiency Exams",
module: "Module 4: Interactive Speaking",
video: "Intro to Interactive Speaking",
questionType: "speaking",
version: "v1",
});
const steps = ["Set Video", "Review & Publish"];
const nextStep = () =>
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (isPublished) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-700 ">
{/* Scalloped Success Icon */}
<div className="mb-10 relative">
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
<img
src={successIcon}
alt="Success"
className="h-[128px] w-[128px] relative"
/>
</div>
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
Practice Attached Successfully!
</h1>
<p className="text-grayScale-600 text-md mb-14 max-w-2xl font-medium leading-relaxed">
The practice has been successfully linked to a video{" "}
<span className="text-[#9E2891]">{formData.video}</span>
</p>
{/* Video Info Card */}
<div className="w-full max-w-[600px] bg-[#9E289114] border border-[#9E2891] rounded-[12px] p-4 flex items-center justify-between mb-16 shadow-sm">
<div className="flex items-center gap-5">
<div className="h-[60px] w-[120px] rounded-xl overflow-hidden shadow-inner flex-shrink-0">
<img
src="https://images.unsplash.com/photo-1557425955-df376b5903c8?auto=format&fit=crop&q=80&w=400"
alt="Video Thumbnail"
className="w-full h-full object-cover"
/>
</div>
<div className="text-left space-y-1.5">
<h4 className="text-[14px] font-medium text-grayScale-900">
Intro to IELTS Speaking Part 1
</h4>
<div className="flex items-center gap-3 text-grayScale-400 font-medium text-[12px]">
<div className="flex items-center gap-1.5 uppercase tracking-wide">
<Clock className="h-3 w-3" />
10:42 mins
</div>
<span></span>
<div className="flex items-center gap-1.5 uppercase tracking-wide">
MP4
</div>
</div>
</div>
</div>
<div className="pr-4">
<Check className="h-5 w-5 text-[#9E2891] stroke-[3px]" />
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-4 w-full max-w-[440px]">
<Button
onClick={() => navigate(backPath)}
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white"
>
Go back to Videos
</Button>
<Button
variant="outline"
onClick={() => {
setIsPublished(false);
setCurrentStep(1);
}}
className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white"
>
Attach More Practice
</Button>
</div>
</div>
);
}
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<AttachPracticeStep1
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
onCancel={() => navigate(backPath)}
/>
);
case 2:
return (
<AttachPracticeReviewStep
formData={formData}
prevStep={prevStep}
onPublish={() => setIsPublished(true)}
/>
);
default:
return null;
}
};
const title =
currentStep === 1 ? "Attach Practice to a Video" : "Review & Publish";
const description =
currentStep === 1
? "Create a new immersive practice session for a video."
: "Verify practice details before publishing it.";
return (
<div className="space-y-8 pb-32 px-6 pt-10 min-h-screen animate-in fade-in duration-500">
<div className="mx-auto w-full">
{/* Navigation Breadcrumb */}
<div className="flex items-center justify-between mb-12">
<Link
to={backPath}
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Videos
</Link>
</div>
{/* Stepper Area */}
<div className="mb-20 w-full pointer-events-none">
<Stepper steps={steps} currentStep={currentStep} />
</div>
{/* Page Title & Header Actions */}
<div className="mb-10 flex items-start justify-between">
<div className="">
<h1 className="text-[30px] font-bold text-[#0D1421] ">{title}</h1>
<p className="text-grayScale-400 text-[16px] font-medium ">
{description}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-100 text-grayScale-600 font-bold bg-white hover:bg-grayScale-50 shadow-sm"
onClick={() => navigate(backPath)}
>
Cancel
</Button>
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-bold text-white shadow-md hover:bg-[#8A237E] transition-all">
Save as Draft
</Button>
</div>
</div>
{/* Form Content */}
<div className="w-full">{renderStep()}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,157 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import successIcon from "../../assets/success.svg";
import { ProgramAttachStep1 } from "./components/practice-steps/ProgramAttachStep1";
import { ProgramAttachReviewStep } from "./components/practice-steps/ProgramAttachReviewStep";
export function AttachProgramPracticeFlow() {
const navigate = useNavigate();
const { programType } = useParams<{ programType: string }>();
const backPath = `/new-content/courses/${programType}`;
const [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false);
const [formData, setFormData] = useState({
program: "English Proficiency Exams",
test: "Mock Exam 1",
questionType: "Speaking Practice",
version: "V 1.0",
});
const steps = ["Set Program", "Review & Publish"];
const nextStep = () =>
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (isPublished) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-700 bg-white">
{/* Scalloped Success Icon */}
<div className="mb-10 relative">
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
<img
src={successIcon}
alt="Success"
className="h-[128px] w-[128px] relative"
/>
</div>
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
Practice Attached Successfully!
</h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
The practice has been successfully linked to the program{" "}
<span className="text-[#9E2891]">{formData.program}</span>
</p>
<div className="flex flex-col gap-4 w-full max-w-[440px]">
<Button
onClick={() => navigate(backPath)}
className="h-14 rounded-[12px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
>
Go back to Program
</Button>
<Button
variant="outline"
onClick={() => {
setIsPublished(false);
setCurrentStep(1);
}}
className="h-14 rounded-[12px] border-[#9E2891] text-[#9E2891] font-bold text-[16px] bg-white "
>
Attach More Practice
</Button>
</div>
</div>
);
}
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<ProgramAttachStep1
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
onCancel={() => navigate(backPath)}
/>
);
case 2:
return (
<ProgramAttachReviewStep
formData={formData}
prevStep={prevStep}
onPublish={() => setIsPublished(true)}
onCancel={() => navigate(backPath)}
/>
);
default:
return null;
}
};
const title =
currentStep === 1 ? "Attach Practice to a program" : "Review & Publish";
const description =
currentStep === 1
? "Create a new immersive practice session for a video."
: "Verify practice details before publishing it.";
return (
<div className="space-y-8 px-6 pt-10 min-h-screen animate-in fade-in duration-500">
<div className=" w-full">
{/* Navigation Breadcrumb */}
<div className="flex items-center justify-between mb-12">
<Link
to={backPath}
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Program
</Link>
</div>
{/* Stepper Area */}
<div className="mb-20 pointer-events-none">
<Stepper steps={steps} currentStep={currentStep} />
</div>
{/* Page Title & Header Actions */}
<div className="mb-10 flex items-start justify-between">
<div className="">
<h1 className="text-[30px] font-bold text-[#0D1421] ">{title}</h1>
<p className="text-grayScale-500 text-[14px]">{description}</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-100 text-grayScale-600 font-bold bg-white hover:bg-grayScale-50 shadow-sm"
onClick={() => navigate(backPath)}
>
Cancel
</Button>
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-bold text-white shadow-md hover:bg-[#8A237E] transition-all">
Save as Draft
</Button>
</div>
</div>
{/* Form Content */}
<div
className={`w-full mx-auto ${
currentStep === 1 ? "max-w-4xl" : "max-w-none"
}`}
>
{renderStep()}
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,264 @@
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
Plus,
FileText,
LayoutGrid,
PlayCircle,
ClipboardCheck,
ChevronRight,
ArrowRight,
X,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import uploadIcon from "../../assets/icons/upload.png";
export function CourseManagementPage() {
const navigate = useNavigate();
const { programType, courseId } = useParams<{
programType: string;
courseId: string;
}>();
// Mock data for display titles
const courseTitles: Record<string, string> = {
duolingo: "Duolingo English Test",
ielts: "IELTS Academic",
};
const courseDisplayName =
courseTitles[courseId || ""] || "Duolingo English Test";
const units = [
{
id: "unit1",
name: "Greetings & Introductions",
description:
"Learn basic greetings, self-introductions, and polite expressions in everyday situations.",
modules: 3,
videos: 9,
practices: 9,
gradient:
"linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
},
{
id: "unit2",
name: "Speaking",
description:
"Core speaking practice and skill building for natural pronunciation and fluency.",
modules: 3,
videos: 9,
practices: 9,
gradient:
"linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)",
},
{
id: "unit3",
name: "Reading",
description:
"Reading comprehension and vocabulary improvement through various text types.",
modules: 3,
videos: 9,
practices: 9,
gradient:
"linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)",
},
];
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
<Link
to={`/new-content/courses/${programType}`}
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Courses
</Link>
{/* Header section */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
{courseDisplayName}
</h1>
<p className="max-w-2xl text-[15px] font-medium leading-relaxed text-grayScale-500">
Manage units and modules inside the {courseDisplayName}
</p>
</div>
<div className="flex items-center gap-3 pt-2">
<Dialog>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2">
<Plus className="h-5 w-5" />
Add Unit
</Button>
</DialogTrigger>
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
<div className="bg-white">
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Courses
</DialogTitle>
</DialogHeader>
<div className="p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Unit Name
</label>
<Input
placeholder="e.g. Reading"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<div className="relative group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
Click to upload
</span>{" "}
<span className="text-grayScale-500">
or drag and drop
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 1 MB)
</p>
</div>
</div>
</div>
</div>
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
>
Cancel
</Button>
</DialogClose>
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
Create Courses
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(`/new-content/courses/${programType}/attach-practice`)
}
>
<FileText className="h-5 w-5" />
Attach Practice
</Button>
</div>
</div>
{/* Horizontal Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
{/* Grid of Units */}
<div className="flex flex-wrap gap-4 pt-4">
{units.map((unit) => (
<Card
key={unit.id}
className="group flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
>
{/* Gradient Header */}
<div
className="h-36 w-full transition-transform duration-500 "
style={{ background: unit.gradient }}
/>
<div className="p-4 flex flex-col flex-1 space-y-6">
<div className="space-y-3 flex-1">
<h3 className="text-[18px] font-medium text-grayScale-900 transition-colors">
{unit.name}
</h3>
<p className="text-[12px] text-grayScale-500 font-medium line-clamp-3">
{unit.description}
</p>
</div>
{/* Stats Pills */}
<div className="flex flex-wrap gap-3">
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
<LayoutGrid className="h-3.5 w-3.5 text-grayScale-400" />
<span className="text-[12px] font-bold">
{unit.modules} Modules
</span>
</div>
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
<span className="text-[12px] font-bold">
{unit.videos} Videos
</span>
</div>
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
<ClipboardCheck className="h-3.5 w-3.5 text-grayScale-400" />
<span className="text-[12px] font-bold">
{unit.practices} Practices
</span>
</div>
</div>
{/* Action Button */}
<Button
className="w-full h-10 bg-brand-500 text-white rounded-[6px] font-bold flex items-center justify-center gap-2 group/btn"
onClick={() =>
navigate(
`/new-content/courses/${programType}/${courseId}/${unit.id}`,
)
}
>
View Detail
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,248 @@
import { useState } from "react";
import { ArrowLeft, Plus, FileText, MoreVertical, Edit2 } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils";
import { Card } from "../../components/ui/card";
const MOCK_VIDEOS = [
{
id: "v1",
title: "1.1 Introduction to Formal Greetings",
duration: "08:45",
status: "Draft",
thumbnailColor: "bg-[#CBD5E1]",
},
{
id: "v2",
title: "1.2 Understanding Email Structure",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#DBEAFE]",
},
{
id: "v3",
title: "1.3 Common Business Idioms",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#FEF3C7]",
},
{
id: "v4",
title: "1.4 Video Conference Etiquette",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#FCE7F3]",
},
];
const MOCK_PRACTICES = [
{
id: "p1",
title: "1.1 Conversation Practice",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#E0F2FE]",
},
{
id: "p2",
title: "1.2 Roleplay Scenario",
duration: "08:45",
status: "Draft",
thumbnailColor: "bg-[#F0FDF4]",
},
];
export function CourseModuleDetailPage() {
const navigate = useNavigate();
const { programType, courseId, unitId, moduleId } = useParams<{
programType: string;
courseId: string;
unitId: string;
moduleId: string;
}>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("All");
const moduleTitle = "Module 1: Basic Phrases";
const moduleDescription = "Learn essential phrases for daily conversations.";
const content = activeTab === "video" ? MOCK_VIDEOS : MOCK_PRACTICES;
const filteredContent = content.filter((item) => {
if (activeFilter === "All") return true;
if (activeFilter === "Drafts") return item.status === "Draft";
return item.status === activeFilter;
});
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
<Link
to={`/new-content/courses/${programType}/${courseId}/${unitId}`}
className="flex items-center gap-2.5 text-[15px] font-bold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Modules
</Link>
{/* Header section */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
{moduleTitle}
</h1>
<p className="max-w-2xl text-[16px] font-medium leading-relaxed text-grayScale-400">
{moduleDescription}
</p>
</div>
<div className="flex items-center gap-3 pt-2">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
onClick={() =>
navigate(
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`,
)
}
>
<FileText className="h-5 w-5" />
Attach Practice
</Button>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
<Plus className="h-5 w-5" />
Add Video
</Button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-10 border-b border-grayScale-100">
<button
onClick={() => setActiveTab("video")}
className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2",
activeTab === "video"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Video
</button>
<button
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div>
{/* Filter Bar */}
<div className="bg-white border border-grayScale-100 rounded-[16px] p-4 flex items-center gap-8 shadow-sm">
<div className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest pl-4">
STATUS:
</div>
<div className="flex items-center gap-2">
{["All", "Published", "Drafts", "Archived"].map((filter) => (
<button
key={filter}
onClick={() => setActiveFilter(filter)}
className={cn(
"px-5 py-2 rounded-full text-[13px] font-bold transition-all",
activeFilter === filter
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-grayScale-100 text-grayScale-500 hover:bg-grayScale-200",
)}
>
{filter}
</button>
))}
</div>
</div>
{/* Grid of Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
{filteredContent.map((item) => (
<ContentCard key={item.id} {...item} />
))}
</div>
</div>
);
}
function ContentCard({
title,
duration,
status,
thumbnailColor,
}: {
title: string;
duration: string;
status: string;
thumbnailColor: string;
}) {
return (
<Card className="group flex flex-col bg-white rounded-[20px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-xl hover:shadow-grayScale-400/5 transition-all">
{/* Thumbnail Area */}
<div className={cn("h-44 w-full relative", thumbnailColor)}>
<div className="absolute bottom-3 right-3 bg-black/60 text-white text-[11px] font-bold px-2 py-0.5 rounded backdrop-blur-sm">
{duration}
</div>
</div>
<div className="p-5 flex flex-col flex-1 space-y-5">
<div className="flex items-center justify-between">
<div
className={cn(
"px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-2 border",
status === "Published"
? "bg-[#F0FDF4] text-[#16A34A] border-[#DCFCE7]"
: "bg-grayScale-50 text-grayScale-400 border-grayScale-100",
)}
>
<div
className={cn(
"h-1.5 w-1.5 rounded-full",
status === "Published" ? "bg-[#16A34A]" : "bg-grayScale-300",
)}
/>
{status}
</div>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-300 hover:text-grayScale-600 transition-colors">
<MoreVertical className="h-5 w-5" />
</button>
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
{title}
</h3>
<div className="pt-2 grid grid-cols-1 gap-2 mt-auto">
<Button
variant="outline"
className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold flex items-center justify-center gap-2 text-xs hover:bg-grayScale-25"
>
<Edit2 className="h-4 w-4" />
Edit
</Button>
<Button
className={cn(
"w-full h-10 rounded-[10px] font-bold text-xs shadow-sm",
status === "Published"
? "bg-[#ECD5E9] text-[#9E2891] hover:bg-[#EBD0E7]"
: "bg-brand-500 text-white hover:bg-brand-600",
)}
>
{status === "Published" ? "Published" : "Publish"}
</Button>
</div>
</div>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep";
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep";
export function CreateQuestionTypeFlow() {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const steps = [
"Basic Info",
"Input & Answer Configuration",
"Versions",
"Review & Publish",
];
const handleNext = () =>
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
return (
<div className="min-h-screen pb-20 overflow-x-hidden">
{/* Header */}
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
<div className="max-w-[1440px] mx-auto py-6">
<div className="flex items-center justify-between mb-8">
<Link
to="/new-content/question-types"
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Question Type Library
</Link>
</div>
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
Create Question Type
</h1>
<p className="text-grayScale-500 text-[14px] font-medium">
Create a new immersive practice session for students.
</p>
</div>
<div className="flex items-center gap-4">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
onClick={() => navigate("/new-content/question-types")}
>
Cancel
</Button>
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all">
Save as Draft
</Button>
</div>
</div>
<div className="mt-12 mx-auto">
<Stepper steps={steps} currentStep={currentStep} />
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />}
{currentStep === 2 && (
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
)}
{currentStep > 2 && (
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm">
<p className="text-grayScale-400 font-medium">
Step {currentStep} implementation in progress...
</p>
<Button onClick={handleBack} variant="outline" className="mt-4">
Go Back
</Button>
</div>
)}
</div>
</div>
);
}

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

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

View File

@ -0,0 +1,676 @@
import { useCallback, useEffect, useState } from "react";
import {
ArrowLeft,
Video,
Calendar,
Mic,
Layers,
Edit2,
Trash2,
X,
} from "lucide-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
deleteTopLevelModuleLesson,
getModuleLessons,
getTopLevelCourseModules,
updateTopLevelModuleLesson,
} from "../../api/courses.api";
import type { TopLevelModuleLessonItem } from "../../types/course.types";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { VideoCard } from "./components/VideoCard";
const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]",
"from-[#DBEAFE] to-[#93C5FD]",
"from-[#FEF3C7] to-[#FCD34D]",
"from-[#FCE7F3] to-[#F9A8D4]",
] as const;
const MOCK_PRACTICES = [
{
id: "p1",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p2",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p3",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p4",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
];
type ModuleDetailState = {
moduleName?: string;
moduleDescription?: string;
};
export function ModuleDetailPage() {
const navigate = useNavigate();
const location = useLocation();
const navState = location.state as ModuleDetailState | null;
const { level, courseId, moduleId } = useParams<{
level: string;
courseId: string;
moduleId: string;
}>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("Draft");
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [editingLesson, setEditingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [editLessonTitle, setEditLessonTitle] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState("");
const [savingLessonEdit, setSavingLessonEdit] = useState(false);
const [thumbUploadBusy, setThumbUploadBusy] = useState(false);
const [videoUploadBusy, setVideoUploadBusy] = useState(false);
const lessonMediaUploadBusy = thumbUploadBusy || videoUploadBusy;
const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
const [practices] = useState(MOCK_PRACTICES);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
>(null);
const [moduleListResolved, setModuleListResolved] = useState(
Boolean(navState?.moduleName?.trim()),
);
const moduleTitleFallback =
moduleId
?.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ") || "Module";
const displayModuleName =
navState?.moduleName?.trim() ||
loadedModuleName ||
moduleTitleFallback;
const hasNavName = Boolean(navState?.moduleName?.trim());
const displayModuleDescription = (() => {
if (hasNavName) {
return navState?.moduleDescription?.trim() || "—";
}
if (!moduleListResolved) {
return "Loading…";
}
if (loadedModuleDescription !== null) {
return loadedModuleDescription.trim() || "—";
}
return "—";
})();
useEffect(() => {
if (navState?.moduleName?.trim()) {
return;
}
const id = Number(moduleId);
const cid = Number(courseId);
if (!Number.isFinite(id) || id < 1 || !Number.isFinite(cid) || cid < 1) {
setModuleListResolved(true);
return;
}
let cancelled = false;
(async () => {
try {
const res = await getTopLevelCourseModules(cid, { limit: 100, offset: 0 });
if (cancelled) return;
const list = res.data?.data?.modules;
if (Array.isArray(list)) {
const m = list.find((mod) => mod.id === id);
if (m) {
setLoadedModuleName(m.name);
setLoadedModuleDescription(m.description ?? "");
} else {
setLoadedModuleName(null);
setLoadedModuleDescription("");
}
} else {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} catch {
if (!cancelled) {
setLoadedModuleName(null);
setLoadedModuleDescription(null);
}
} finally {
if (!cancelled) {
setModuleListResolved(true);
}
}
})();
return () => {
cancelled = true;
};
}, [navState?.moduleName, courseId, moduleId]);
const loadModuleLessons = useCallback(
async (options?: { showPageLoading?: boolean }) => {
const showPageLoading = options?.showPageLoading ?? true;
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
setLessons([]);
setLessonsLoadError(null);
setLessonsLoading(false);
return;
}
if (showPageLoading) {
setLessonsLoading(true);
setLessonsLoadError(null);
}
try {
const res = await getModuleLessons(mid, { limit: 100, offset: 0 });
const list = res.data?.data?.lessons;
if (Array.isArray(list)) {
setLessons(
[...list].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
),
);
} else {
setLessons([]);
}
if (showPageLoading) {
setLessonsLoadError(null);
}
} catch {
if (showPageLoading) {
setLessons([]);
setLessonsLoadError("Failed to load lessons. Please try again.");
} else {
toast.error("Failed to refresh lessons");
}
} finally {
if (showPageLoading) {
setLessonsLoading(false);
}
}
},
[moduleId],
);
useEffect(() => {
void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]);
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? "");
setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? "");
};
const closeEditLesson = () => {
if (savingLessonEdit || lessonMediaUploadBusy) return;
setEditingLesson(null);
};
const handleSaveLessonEdit = async () => {
if (!editingLesson) return;
const title = editLessonTitle.trim();
if (!title) {
toast.error("Title is required");
return;
}
setSavingLessonEdit(true);
try {
await updateTopLevelModuleLesson(editingLesson.id, {
title,
video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(),
});
toast.success("Lesson updated");
setEditingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson";
toast.error(msg);
} finally {
setSavingLessonEdit(false);
}
};
const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return;
setDeletingLessonInFlight(true);
try {
await deleteTopLevelModuleLesson(deletingLesson.id);
toast.success("Lesson deleted");
setDeletingLesson(null);
await loadModuleLessons({ showPageLoading: false });
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete lesson";
toast.error(msg);
} finally {
setDeletingLessonInFlight(false);
}
};
return (
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
{/* Header Navigation */}
<div className="flex items-center gap-2">
<Link
to={`/new-content/learn-english/${level}/courses/${courseId}`}
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500"
>
<ArrowLeft className="h-5 w-5" />
Back to Modules
</Link>
</div>
{/* Hero Section */}
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
<div className="">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
{displayModuleName}
</h1>
<p className="text-grayScale-500 text-[14px] max-w-2xl">
{displayModuleDescription}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
)
}
>
<Calendar className="h-4 w-4" />
Add Practice
</Button>
<Button
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
)
}
>
<div className="h-4 w-4 flex items-center justify-center">
<span className="text-xl leading-none font-light">+</span>
</div>
Add Lesson
</Button>
</div>
</div>
{/* Tabs */}
<div className="border-b border-grayScale-200">
<div className="flex gap-10">
<button
onClick={() => setActiveTab("video")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "video"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Lesson
</button>
<button
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div>
</div>
{/* Content */}
<div className="mt-8">
{activeTab === "video" ? (
lessonsLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
Loading lessons
</div>
) : lessonsLoadError ? (
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
<VideoCard
key={lesson.id}
id={lesson.id}
title={lesson.title}
videoUrl={lesson.video_url}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
No lessons in this module yet
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
Lessons are a great way to engage students. Add your first
lesson to get started.
</p>
<Button
variant="outline"
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
)
}
>
<Video className="h-5 w-5" />
Add Lesson
</Button>
</div>
)
) : (
<div className="space-y-8">
{/* Practice Tab Filter Bar */}
<div className="bg-white border border-grayScale-100 rounded-2xl p-4 flex items-center gap-10 shadow-sm overflow-x-auto whitespace-nowrap px-8">
<div className="flex items-center gap-2 text-[12px] font-bold text-grayScale-300 uppercase tracking-widest mr-2">
STATUS:
</div>
<div className="flex items-center gap-3">
{["All", "Published", "Draft", "Archived"].map((label) => (
<button
key={label}
onClick={() => setActiveFilter(label)}
className={cn(
"h-9 px-5 rounded-full text-[13px] font-bold transition-all",
activeFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{label}
</button>
))}
</div>
</div>
{/* Practice Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{practices.map((practice) => (
<PracticeCard key={practice.id} {...practice} />
))}
</div>
</div>
)}
</div>
<Dialog
open={editingLesson !== null}
onOpenChange={(open) => {
if (!open && (savingLessonEdit || lessonMediaUploadBusy)) return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-title"
>
Title
</label>
<Input
id="edit-lesson-title"
value={editLessonTitle}
onChange={(e) => setEditLessonTitle(e.target.value)}
disabled={savingLessonEdit}
/>
</div>
<LessonMediaUploadField
kind="video"
value={editLessonVideoUrl}
onChange={setEditLessonVideoUrl}
disabled={savingLessonEdit}
onUploadBusyChange={setVideoUploadBusy}
/>
<LessonMediaUploadField
kind="thumbnail"
value={editLessonThumbnail}
onChange={setEditLessonThumbnail}
disabled={savingLessonEdit}
onUploadBusyChange={setThumbUploadBusy}
/>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-desc"
>
Description
</label>
<Textarea
id="edit-lesson-desc"
value={editLessonDescription}
onChange={(e) => setEditLessonDescription(e.target.value)}
rows={4}
disabled={savingLessonEdit}
className="min-h-[100px] resize-y"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={closeEditLesson}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void handleSaveLessonEdit()}
disabled={savingLessonEdit || lessonMediaUploadBusy}
>
{savingLessonEdit ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deletingLesson && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">
Delete lesson
</h2>
<button
type="button"
onClick={() =>
!deletingLessonInFlight && setDeletingLesson(null)
}
disabled={deletingLessonInFlight}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-6">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
<Trash2 className="h-5 w-5 text-red-500" />
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{deletingLesson.title}
</span>
? This cannot be undone.
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setDeletingLesson(null)}
disabled={deletingLessonInFlight}
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
type="button"
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
disabled={deletingLessonInFlight}
onClick={() => void handleConfirmDeleteLesson()}
>
{deletingLessonInFlight ? "Deleting…" : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function PracticeCard({
title,
level,
variations,
status,
}: {
title: string;
level: string;
variations: number;
status: string;
}) {
return (
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
{title}
</h3>
</div>
<div className="flex items-center justify-between gap-3">
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
{level}
</span>
<div className="flex items-center gap-1.5 text-grayScale-500">
<Mic className="h-4 w-4" />
<span className="text-[13px] font-bold">Speaking</span>
</div>
</div>
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
<Layers className="h-4 w-4" />
<span className="text-[14px] font-bold">{variations} Variations</span>
</div>
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
{status}
</div>
<div className="flex items-center gap-3">
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
Publish Practice
</Button>
<Button
variant="outline"
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
>
Publish Video
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,83 @@
import { Link } from "react-router-dom";
import { Mic } from "lucide-react";
import { Card, CardContent } from "../../components/ui/card";
import { Button } from "../../components/ui/button";
export function NewContentPage() {
return (
<div className="space-y-8">
{/* Header section */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Content Management
</h1>
<p className="mt-1 text-sm text-grayScale-500">
Upload, organize, and manage learning content across programs and
courses
</p>
</div>
{/* Gradient Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-grayScale-100" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
{/* Cards Grid */}
<div className="grid max-w-5xl gap-8 grid-cols-1 md:grid-cols-2">
{/* Learn English Card */}
<Card className="overflow-hidden border-none shadow-soft">
<div className="flex h-56 items-center justify-center bg-white/50">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand-100/30">
<Mic className="h-10 w-10 text-brand-500" />
</div>
</div>
<CardContent className="border-t border-grayScale-200 bg-white p-8 text-center">
<h3 className="text-xl font-bold text-grayScale-700">
Learn English
</h3>
<p className="mt-3 text-sm leading-relaxed text-grayScale-500">
Manage structured English learning content based on levels and
modules.
</p>
<Link to="/new-content/learn-english">
<Button className="mt-8 h-12 w-full rounded-[6px] bg-brand-500 text-base font-semibold ">
Manage Learn English
</Button>
</Link>
</CardContent>
</Card>
{/* Courses Card */}
<Card className="overflow-hidden border-none shadow-soft">
<div className="flex h-56 items-center justify-center bg-white/50">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand-100/30">
<Mic className="h-10 w-10 text-brand-500" />
</div>
</div>
<CardContent className="border-t border-grayScale-200 bg-white p-8 text-center">
<h3 className="text-xl font-bold text-grayScale-700">Courses</h3>
<p className="mt-3 text-sm leading-relaxed text-grayScale-500">
Manage skill-based and exam preparation courses such as Duolingo
and IELTS.
</p>
<Link to="/new-content/courses" className="block w-full">
<Button className="mt-8 h-12 w-full rounded-[6px] bg-brand-500 text-base font-semibold ">
Manage Courses
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,273 @@
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
Plus,
FileText,
ClipboardList,
ListChecks,
ChevronRight,
X,
Upload,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import uploadIcon from "../../assets/icons/upload.png";
export function ProgramDetailPage() {
const navigate = useNavigate();
const { programType } = useParams<{ programType: string }>();
// Mock data for "proficiency" program type
const programs: Record<string, any> = {
proficiency: {
title: "English Proficiency Exams",
description:
"Manage exam-based learning programs such as Duolingo and IELTS.",
courses: [
{
id: "duolingo",
name: "Duolingo English Test",
description:
"Adaptive exam-style practice for speaking, writing, reading, and listening.",
coursesCount: 6,
questionTypesCount: 13,
logo: (
<div className="h-14 w-14 rounded-full bg-[#FFB800] flex items-center justify-center relative overflow-hidden">
{/* Simple Duolingo-like representation if image not available */}
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent" />
<div className="h-8 w-8 bg-white rounded-full flex items-center justify-center">
<div className="h-4 w-4 bg-[#FFB800] rounded-sm transform rotate-45" />
</div>
</div>
),
buttonText: "Manage Detail",
},
{
id: "ielts",
name: "IELTS Academic",
description:
"Full preparation for IELTS speaking, writing, listening, and reading.",
coursesCount: 4,
questionTypesCount: 18,
logo: (
<div className="flex items-center gap-1">
<span className="text-[28px] font-black tracking-tighter text-[#E11D48] ">
IELTS
</span>
<span className="text-[8px] font-bold text-[#E11D48] mt-2 tracking-widest uppercase">
</span>
</div>
),
buttonText: "View Detail",
},
],
},
"skill-based": {
title: "Skill-Based Courses",
description:
"Practice-focused communication and skills training for real-world scenarios.",
courses: [], // To be implemented or shown if needed
},
};
const currentProgram =
programs[programType || "proficiency"] || programs.proficiency;
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
<Link
to="/new-content/courses"
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back
</Link>
{/* Header section */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<h1 className="text-[26px] font-medium tracking-tight text-grayScale-900">
{currentProgram.title}
</h1>
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
{currentProgram.description}
</p>
</div>
<div className="flex items-center gap-3 pt-2">
<Dialog>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2">
<Plus className="h-5 w-5" />
Create Course
</Button>
</DialogTrigger>
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
<div className="bg-white">
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Course
</DialogTitle>
</DialogHeader>
<div className="p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Name
</label>
<Input
placeholder="e.g. TOEFL, IELTS"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Course Order
</label>
<Select defaultValue="1">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</Select>
</div>
{/* Thumbnail Field */}
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<div className="relative group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
Click to upload
</span>{" "}
<span className="text-grayScale-500">
or drag and drop
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 1 MB)
</p>
</div>
</div>
</div>
</div>
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
>
Cancel
</Button>
</DialogClose>
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
Create Program
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
onClick={() =>
navigate(`/new-content/courses/${programType}/attach-practice`)
}
>
<FileText className="h-5 w-5" />
Attach Practice
</Button>
</div>
</div>
{/* Gradient Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
{/* Cards Grid */}
<div className="flex flex-wrap gap-8 mt-10">
{currentProgram.courses.map((course: any) => (
<Card
key={course.id}
className="bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
>
{/* Logo */}
<div className="h-16 flex items-center">{course.logo}</div>
{/* Content */}
<div className="space-y-4 pt-2 flex-1">
<h3 className="text-[18px] font-medium text-grayScale-900">
{course.name}
</h3>
<p className="text-[14px] text-grayScale-500 font-medium">
{course.description}
</p>
</div>
{/* Badges/Stats */}
<div className="flex items-center pt-4 gap-4">
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
<ClipboardList className="h-3 w-3 text-grayScale-400" />
<span className="text-[12px] ">
{course.coursesCount} Courses
</span>
</div>
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
<ListChecks className="h-3 w-3 text-grayScale-400" />
<span className="text-[12px] ">
{course.questionTypesCount} Question Types
</span>
</div>
</div>
{/* Action Button */}
<Button
className="w-full mt-4 h-10 bg-brand-500 text-white rounded-[8px] font-bold flex items-center justify-center gap-2 group/btn"
onClick={() =>
navigate(`/new-content/courses/${programType}/${course.id}`)
}
>
{course.buttonText}
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
</Button>
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { Link } from "react-router-dom";
import { GraduationCap, Brain } from "lucide-react";
import { Button } from "../../components/ui/button";
export function ProgramTypeSelectionPage() {
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header section */}
<div className="flex items-start justify-between">
<div className="space-y-1.5 pt-2">
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
Courses
</h1>
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
Organize courses under skill-based learning or English proficiency
exams. Select a program type to manage curriculum and modules.
</p>
</div>
<Link to="/new-content/question-types">
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
Manage Question Types
</Button>
</Link>
</div>
{/* Gradient Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
{/* Selection Cards Grid */}
<div className="flex flex-warp gap-10 pt-4">
{/* Skill-Based Courses Card */}
<Link to="/new-content/courses/skill-based" className="group h-full">
<div className="bg-white rounded-[6px] w-[500px] border border-grayScale-100 px-10 py-12 h-full transition-all flex flex-col items-start gap-10">
<div className="h-16 w-16 rounded-full bg-brand-50/10 flex items-center justify-center">
<Brain className="h-8 w-8 text-brand-500" />
</div>
<div className="space-y-3 flex-1">
<h3 className="text-[20px] font-bold text-grayScale-900">
Skill-Based Courses
</h3>
<p className="text-[15px] leading-relaxed text-grayScale-500 font-medium">
Practice-focused communication and skills training. Create
modules for vocabulary, grammar, and real-world conversation
scenarios.
</p>
</div>
</div>
</Link>
{/* English Proficiency Exams Card */}
<Link to="/new-content/courses/proficiency" className="group h-full">
<div className="bg-white w-[500px] rounded-[6px] border border-grayScale-100 px-10 py-12 h-full transition-all flex flex-col items-start gap-10">
<div className="h-16 w-16 rounded-full bg-brand-50/10 flex items-center justify-center">
<GraduationCap className="h-8 w-8 text-brand-500" />
</div>
<div className="space-y-3 flex-1">
<h3 className="text-[20px] font-bold text-grayScale-900 transition-colors">
English Proficiency Exams
</h3>
<p className="text-[15px] leading-relaxed text-grayScale-500 font-medium">
Exam preparation courses such as IELTS, and Duolingo. Structure
content by band scores, sections, and mock tests.
</p>
</div>
</div>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,127 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { ArrowLeft, Plus, Search } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Card } from "../../components/ui/card";
import { cn } from "../../lib/utils";
import { QuestionTypeCard } from "./components/QuestionTypeCard";
export function QuestionTypeLibraryPage() {
const [activeTab, setActiveTab] = useState("All");
const questionTypes = [
{
title: "Describe a Photo",
exam: "DUOLINGO" as const,
skill: "Speaking" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Write About the Topic",
exam: "DUOLINGO" as const,
skill: "Writing" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Fill in the Blanks",
exam: "IELTS" as const,
skill: "Writing" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Describe a Photo",
exam: "DUOLINGO" as const,
skill: "Speaking" as const,
variations: 12,
status: "Published" as const,
},
];
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
{/* Navigation & Header */}
<div className="space-y-6">
<Link
to="/new-content/courses"
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Courses
</Link>
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">
Question Type Library
</h1>
<p className="text-grayScale-500 text-[16px] font-medium">
Create and manage reusable question structures for practices and
assessments.
</p>
</div>
<Link to="/new-content/question-types/create">
<Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3">
<Plus className="h-5 w-5" />
Create Question Type
</Button>
</Link>
</div>
</div>
{/* Control Bar */}
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
<Input
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
placeholder="Search by practice name, ID, or keywords..."
/>
</div>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Exams</option>
<option>IELTS</option>
<option>Duolingo</option>
</Select>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Skills</option>
<option>Speaking</option>
<option>Writing</option>
</Select>
</div>
<div className="flex items-center gap-3">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
STATUS:
</span>
{["All", "Published", "Drafts", "Archived"].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
activeTab === tab
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
>
{tab}
</button>
))}
</div>
</Card>
{/* Grid of Cards */}
<div className="grid grid-cols-4 gap-6">
{questionTypes.map((qt, index) => (
<QuestionTypeCard key={index} {...qt} />
))}
</div>
</div>
);
}

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,254 @@
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
Plus,
MessageCircle,
PlayCircle,
ClipboardCheck,
ArrowRight,
X,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import uploadIcon from "../../assets/icons/upload.png";
export function UnitManagementPage() {
const navigate = useNavigate();
const { programType, courseId, unitId } = useParams<{
programType: string;
courseId: string;
unitId: string;
}>();
// Mock titles
const unitTitles: Record<string, string> = {
unit1: "Greetings & Introductions",
unit2: "Speaking",
unit3: "Reading",
};
const unitDisplayName =
unitTitles[unitId || ""] || "Greetings & Introductions";
const modules = [
{
id: "mod1",
name: "Module 1: Basic Phrases",
description: "Learn essential phrases for daily conversations.",
videos: 3,
practices: 3,
gradient:
"linear-gradient(135deg, rgba(158, 40, 145, 0.4) 0%, rgba(158, 40, 145, 0.7) 100%)",
},
{
id: "mod2",
name: "Module 1: Basic Phrases", // Matching Image 2092-1 labels
description: "Learn essential phrases for daily conversations.",
videos: 3,
practices: 3,
gradient:
"linear-gradient(135deg, rgba(79, 70, 229, 0.4) 0%, rgba(79, 70, 229, 0.7) 100%)",
},
{
id: "mod3",
name: "Module 1: Basic Phrases",
description: "Learn essential phrases for daily conversations.",
videos: 3,
practices: 3,
gradient:
"linear-gradient(135deg, rgba(124, 58, 237, 0.4) 0%, rgba(124, 58, 237, 0.7) 100%)",
},
];
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
<Link
to={`/new-content/courses/${programType}/${courseId}`}
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Courses
</Link>
{/* Header section */}
<div className="flex items-start justify-between">
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
{unitDisplayName}
</h1>
<Dialog>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2">
<Plus className="h-5 w-5" />
Add Modules
</Button>
</DialogTrigger>
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
<div className="bg-white">
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Modules
</DialogTitle>
<DialogClose className="rounded-full p-1.5 hover:bg-grayScale-50 transition-colors">
<X className="h-5 w-5 text-grayScale-400" />
<span className="sr-only">Close</span>
</DialogClose>
</DialogHeader>
<div className="p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Module Title
</label>
<Input
placeholder="e.g. 1.1 Exam types"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Module Order
</label>
<Select defaultValue="1">
<option value="1">1</option>
<option value="2">2</option>
</Select>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Icon</label>
<div className="relative group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
Click to upload
</span>{" "}
<span className="text-grayScale-500">
or drag and drop
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 1 MB)
</p>
</div>
</div>
</div>
</div>
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-200 flex justify-end gap-3">
<DialogClose asChild>
<Button
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
>
Cancel
</Button>
</DialogClose>
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
Create Module
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Gradient Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
{/* Grid of Modules */}
<div className="flex flex-wrap gap-4 pt-4">
{modules.map((module, index) => (
<Card
key={`${module.id}-${index}`}
className="group flex w-[400px] flex-col bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
>
{/* Gradient Header */}
<div
className="h-36 w-full"
style={{ background: module.gradient }}
/>
<div className="p-5 flex flex-col space-y-4">
<div className="flex items-start gap-3">
{/* Chat Icon */}
<div className="mt-1 h-10 w-10 shrink-0 rounded-full bg-[#9E28911A] border border-[#9E289133] flex items-center justify-center">
<MessageCircle className="h-5 w-5 text-brand-500" />
</div>
<div className="space-y-1">
<h3 className="text-[16px] font-medium text-grayScale-900 leading-tight">
{module.name}
</h3>
<p className="text-[12px] text-grayScale-500 font-medium">
{module.description}
</p>
</div>
</div>
{/* Stats Pills */}
<div className="flex items-center gap-3">
<div className="h-8 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
<span className="text-[12px] font-bold">
{module.videos} Videos
</span>
</div>
<div className="h-8 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
<ClipboardCheck className="h-3.5 w-3.5 text-grayScale-400" />
<span className="text-[12px] font-bold">
{module.practices} Practices
</span>
</div>
</div>
{/* Action Button */}
<Button
className="w-full h-10 bg-brand-500 text-white rounded-[6px] font-bold flex items-center justify-center gap-2 group/btn"
onClick={() =>
navigate(
`/new-content/courses/${programType}/${courseId}/${unitId}/${module.id}`,
)
}
>
View Detail
<ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover/btn:translate-x-1" />
</Button>
</div>
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,190 @@
import { useEffect, useState, type FormEvent } from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "sonner";
import { createTopLevelCourseModule } from "../../../api/courses.api";
import { ModuleIconUploadField } from "./ModuleIconUploadField";
interface AddModuleModalProps {
isOpen: boolean;
onClose: () => void;
courseId: number;
onCreated?: () => void | Promise<void>;
}
export function AddModuleModal({
isOpen,
onClose,
courseId,
onCreated,
}: AddModuleModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [submitting, setSubmitting] = useState(false);
const [iconUploadBusy, setIconUploadBusy] = useState(false);
useEffect(() => {
if (isOpen) {
setName("");
setDescription("");
setIcon("");
setSubmitting(false);
setIconUploadBusy(false);
}
}, [isOpen]);
const resetAndClose = () => {
setName("");
setDescription("");
setIcon("");
setIconUploadBusy(false);
onClose();
};
const handleOpenChange = (open: boolean) => {
if (!open && (submitting || iconUploadBusy)) return;
if (!open) {
resetAndClose();
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("Module name is required");
return;
}
if (!Number.isFinite(courseId) || courseId < 1) {
toast.error("Invalid course");
return;
}
setSubmitting(true);
try {
await createTopLevelCourseModule(courseId, {
name: trimmedName,
description: description.trim(),
icon: icon.trim(),
});
toast.success("Module created");
if (onCreated) {
await onCreated();
}
resetAndClose();
} catch (err: unknown) {
console.error(err);
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create module";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0 shadow-2xl">
<div className="flex-shrink-0">
<DialogHeader className="relative p-8 pb-4">
<DialogTitle className="text-2xl font-bold text-grayScale-700">
Add New Module
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a module with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /courses/:courseId/modules
</code>
.
</DialogDescription>
</DialogHeader>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-100" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20"
style={{ background: "gray" }}
/>
</div>
</div>
</div>
<form
className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain p-8 pt-4"
onSubmit={(e) => void handleSubmit(e)}
>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Module title
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Greetings & Introductions"
className="h-12 rounded-xl"
disabled={submitting}
required
/>
</div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Learn to introduce yourself and talk about your life."
className="min-h-[88px] resize-y rounded-xl"
disabled={submitting}
rows={3}
/>
</div>
<ModuleIconUploadField
value={icon}
onChange={setIcon}
disabled={submitting}
onUploadBusyChange={setIconUploadBusy}
/>
<div className="flex justify-end gap-3 pt-4">
<DialogClose asChild>
<Button
type="button"
variant="outline"
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
disabled={submitting || iconUploadBusy}
>
Cancel
</Button>
</DialogClose>
<Button
type="submit"
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold text-white shadow-lg shadow-brand-500/20 hover:bg-brand-600"
disabled={submitting || iconUploadBusy}
>
{submitting ? "Creating…" : "Create module"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

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

@ -0,0 +1,83 @@
import { Edit2, Trash2, Mic2, Keyboard, Layers, MicIcon } from "lucide-react";
import { Badge } from "../../../components/ui/badge";
import { Card } from "../../../components/ui/card";
import { cn } from "../../../lib/utils";
interface QuestionTypeCardProps {
title: string;
exam: "DUOLINGO" | "IELTS" | "TOEFL";
skill: "Speaking" | "Writing" | "Listening" | "Reading";
variations: number;
status: "Published" | "Draft" | "Archived";
}
export function QuestionTypeCard({
title,
exam,
skill,
variations,
status,
}: QuestionTypeCardProps) {
const SkillIcon = skill === "Speaking" ? MicIcon : Keyboard;
const examColors = {
DUOLINGO: "bg-[#22C55EE5] text-[#fff] border-transparent",
IELTS: "bg-[#EF4444E5] text-[#fff] border-transparent",
TOEFL: "bg-[#DBEAFE] text-[#fff] border-transparent",
};
const statusColors = {
Published: "bg-[#F0FDF4] text-[#16A34A]",
Draft: "bg-grayScale-50 text-grayScale-500",
Archived: "bg-red-50 text-red-500",
};
return (
<Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300">
<div className="px-4 py-6 space-y-8">
<h3 className="text-[20px] font-bold text-grayScale-900 leading-[1.2]">
{title}
</h3>
<div className="flex items-center justify-between">
<Badge
className={cn(
"px-3 py-1 rounded-[4px] text-[11px] font-bold tracking-wider shadow-none border-none",
examColors[exam],
)}
>
{exam}
</Badge>
<div className="flex items-center gap-1 text-grayScale-900 font-bold text-[13px]">
<SkillIcon className="h-4 w-4" />
{skill}
</div>
</div>
<div className="flex items-center gap-2.5 text-[#9E2891] font-medium text-[15px]">
<Layers className="h-[16px] w-[16px]" />
{variations} Variations
</div>
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200">
<Badge
className={cn(
"px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none",
statusColors[status],
)}
>
{status}
</Badge>
<div className="flex items-center gap-5 transition-opacity">
<button className="text-grayScale-500/70 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="text-grayScale-500/70 transition-all">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,375 @@
import { useEffect, useMemo, useState } from "react";
import { MoreVertical, Edit2, Play, Pencil, Trash2 } from "lucide-react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { isAdminOrSuperAdminRole } from "../../../lib/sessionRole";
import { cn } from "../../../lib/utils";
import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
getVideoPreview,
} from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
interface VideoCardProps {
id?: string | number;
title: string;
/** Omits the duration chip when not provided (e.g. API has no length yet). */
duration?: string;
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
status?: "Draft" | "Published";
thumbnailGradient?: string;
thumbnailUrl?: string | null;
/**
* When set, the hover play control opens a preview (Vimeo, YouTube, or direct
* video file) in a dialog.
*/
videoUrl?: string;
/**
* When true, shows edit/delete in the top-right of the thumbnail (same
* hover pattern as module cards) and removes the footer + overflow menu.
*/
hoverModuleActions?: boolean;
onEdit?: () => void;
onDelete?: () => void;
onPublish?: () => void;
}
export function VideoCard({
title,
duration,
status,
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
thumbnailUrl,
videoUrl,
onEdit,
onDelete,
onPublish,
hoverModuleActions = false,
}: VideoCardProps) {
const [thumbFailed, setThumbFailed] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
/** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false);
const [iframeSessionKey, setIframeSessionKey] = useState(0);
const useGradient = !thumbnailUrl?.trim() || thumbFailed;
const videoPreview = useMemo(
() => (videoUrl?.trim() ? getVideoPreview(videoUrl) : { kind: "none" as const }),
[videoUrl],
);
const limitedEmbedSrc = useMemo(() => {
if (videoPreview.kind !== "iframe") return null;
return applyShortPreviewToEmbedUrl(
videoPreview.src,
videoPreview.label,
DEFAULT_PREVIEW_MAX_SECONDS,
);
}, [videoPreview]);
const canPreview = Boolean(videoUrl?.trim());
const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS,
);
useEffect(() => {
if (!previewOpen) {
setIframeSessionDone(false);
return;
}
if (videoPreview.kind !== "iframe" || !limitedEmbedSrc) {
return;
}
if (iframeSessionDone) {
return;
}
const ms = DEFAULT_PREVIEW_MAX_SECONDS * 1000;
const id = window.setTimeout(() => {
setIframeSessionDone(true);
}, ms);
return () => window.clearTimeout(id);
}, [
previewOpen,
videoPreview.kind,
limitedEmbedSrc,
iframeSessionDone,
]);
const handlePreviewOpenChange = (open: boolean) => {
setPreviewOpen(open);
if (!open) {
setIframeSessionDone(false);
setIframeSessionKey((k) => k + 1);
}
};
return (
<div
className={cn(
"group relative bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col",
)}
>
{/* Thumbnail */}
<div
className={cn(
"relative h-44 w-full overflow-hidden",
useGradient && "bg-gradient-to-br",
useGradient && thumbnailGradient,
!useGradient && "bg-grayScale-100",
)}
>
{hoverModuleActions && (onEdit || onDelete) ? (
<div
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
>
{onEdit ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${title}`}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${title}`}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
) : null}
{!useGradient && thumbnailUrl ? (
<img
src={thumbnailUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbFailed(true)}
/>
) : null}
{/* Duration Badge */}
{duration ? (
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
{duration}
</div>
) : null}
{/* Play: opens preview dialog when videoUrl is set */}
{canPreview ? (
<button
type="button"
className="absolute inset-0 z-[8] flex cursor-pointer items-center justify-center bg-gradient-to-b from-black/0 via-black/20 to-black/30 opacity-0 transition-all duration-300 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setPreviewOpen(true);
}}
aria-label={`Play preview: ${title}`}
>
<span className="flex h-12 w-12 items-center justify-center rounded-full border border-white/40 bg-white/20 shadow-lg backdrop-blur-md transition-transform duration-300 group-hover:scale-105 group-hover:border-white/50 group-hover:bg-white/30">
<Play className="h-6 w-6 text-white" fill="currentColor" />
</span>
</button>
) : (
<div className="pointer-events-none absolute inset-0 z-[5] flex items-center justify-center bg-black/10 opacity-0 transition-opacity group-hover:opacity-100">
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
<Play className="h-6 w-6 text-white fill-current" />
</div>
</div>
)}
</div>
<Dialog open={previewOpen} onOpenChange={handlePreviewOpenChange}>
<DialogContent
className="max-w-4xl w-[min(100vw-1.5rem,56rem)] gap-0 overflow-hidden rounded-2xl border border-grayScale-200 p-0 shadow-2xl"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="border-b border-grayScale-100 bg-gradient-to-r from-[#F8FAFC] to-white px-5 py-4 pr-12 sm:px-6 sm:pr-14">
<DialogHeader className="space-y-0.5 p-0 text-left">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-brand-500">
Short preview
</p>
<DialogTitle className="line-clamp-2 text-left text-base font-bold leading-snug text-grayScale-900 sm:text-lg">
{title}
</DialogTitle>
<p className="pt-0.5 text-left text-xs font-medium text-grayScale-500">
The player closes automatically after {previewLengthLabel} in
this window (YouTube/Vimeo cant be trimmed reliably). For the
full lesson, use your LMS app.
</p>
</DialogHeader>
</div>
<div className="bg-black">
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
iframeSessionDone ? (
<div className="flex min-h-[220px] flex-col items-center justify-center gap-3 bg-gradient-to-b from-grayScale-900 to-grayScale-950 px-6 py-10 text-center">
<p className="text-sm font-semibold text-white">
Preview time in this window has ended
</p>
<p className="max-w-sm text-xs text-white/60">
The embed is removed after {previewLengthLabel} of real time
so the full video is not available here.
</p>
<Button
type="button"
size="sm"
variant="secondary"
className="mt-1 font-bold"
onClick={() => {
setIframeSessionDone(false);
setIframeSessionKey((k) => k + 1);
}}
>
Start preview again
</Button>
{videoUrl && isAdminOrSuperAdminRole() ? (
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-semibold text-brand-300 underline-offset-2 hover:underline"
>
Open full video in new tab
</a>
) : null}
</div>
) : (
<div className="relative aspect-video w-full">
<iframe
key={`${iframeSessionKey}-${limitedEmbedSrc}`}
src={limitedEmbedSrc}
title={`${videoPreview.label} preview: ${title}`}
className="absolute inset-0 h-full w-full"
allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
allowFullScreen
/>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
Stops in {previewLengthLabel} (hard limit)
</div>
</div>
)
) : videoPreview.kind === "video" ? (
<PreviewLimitedFileVideo
src={videoPreview.src}
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
/>
) : (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 bg-grayScale-900 px-6 py-10 text-center">
<p className="text-sm font-medium text-white/90">
This link cant be played inline
</p>
<p className="max-w-sm text-xs text-white/50">
Use a Vimeo, YouTube, or direct URL to a video file (e.g. MP4)
for an embedded preview.
</p>
{videoUrl ? (
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 text-sm font-semibold text-brand-300 underline-offset-2 hover:underline"
>
Open in new tab
</a>
) : null}
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Content */}
<div className="p-5 space-y-4 flex-1 flex flex-col">
<div
className={cn(
"flex items-center gap-2",
hoverModuleActions ? "justify-start" : "justify-between",
)}
>
{/* Status Badge */}
{status ? (
<div
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0",
status === "Published"
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
)}
>
<div
className={cn(
"h-1.5 w-1.5 rounded-full flex-shrink-0",
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
)}
/>
{status}
</div>
) : (
<div className="flex min-w-0 items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border border-[#E5E7EB] bg-grayScale-50 text-grayScale-500">
<div className="h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#9CA3AF]" />
Lesson
</div>
)}
{!hoverModuleActions ? (
<button
type="button"
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"
>
<MoreVertical className="h-5 w-5" />
</button>
) : null}
</div>
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
{title}
</h3>
{/* Actions (footer) — not used for API lesson cards with hover tools */}
{!hoverModuleActions ? (
<div className="pt-2 space-y-3 mt-auto">
<Button
variant="outline"
onClick={onEdit}
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
>
<Edit2 className="h-4 w-4" />
Edit
</Button>
{status ? (
<Button
disabled={status === "Published"}
onClick={onPublish}
className={cn(
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
status === "Published"
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
)}
>
{status === "Published" ? "Published" : "Publish"}
</Button>
) : null}
</div>
) : null}
</div>
</div>
);
}

View File

@ -0,0 +1,232 @@
import {
Rocket,
GraduationCap,
Folder,
ChevronUp,
ChevronDown,
Eye,
Video as VideoIcon,
ClipboardList,
} from "lucide-react";
import { useState } from "react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { cn } from "../../../../lib/utils";
interface AttachPracticeReviewStepProps {
formData: any;
prevStep: () => void;
onPublish: () => void;
}
export function AttachPracticeReviewStep({
formData,
prevStep,
onPublish,
}: AttachPracticeReviewStepProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [isConfirmed, setIsConfirmed] = useState(false);
const questions = [
{ order: "01", text: "What is the main idea of the passage?" },
{ order: "02", text: "What does the speaker mainly talk about in the..." },
{ order: "03", text: "Which option best completes the sentence?" },
];
return (
<div className="space-y-10 animate-in fade-in duration-500 mx-auto">
{/* 1. Video Summary Card */}
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white overflow-hidden">
<div className="flex gap-8 items-start">
{/* Thumbnail */}
<div className="relative h-[150px] w-[260px] rounded-xl overflow-hidden shadow-inner flex-shrink-0">
<img
src="https://images.unsplash.com/photo-1557425955-df376b5903c8?auto=format&fit=crop&q=80&w=600"
alt="Video Thumbnail"
className="w-full h-full object-cover"
/>
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-[11px] font-bold px-2 py-0.5 rounded flex items-center gap-1.5">
<VideoIcon className="h-3 w-3" />
12:30
</div>
</div>
{/* Details */}
<div className="pt-12">
<h3 className="text-[20px] font-bold text-grayScale-900 leading-tight">
Intro to Interactive Speaking
</h3>
<div className="flex flex-wrap gap-3">
<div className="h-8 pr-2 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
<GraduationCap className="h-4 w-4" />
IELTS
</div>
<div className="h-8 pr-2 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
<Folder className="h-4 w-4" />
Unit 2: Speaking
</div>
<div className="h-8 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
<Folder className="h-4 w-4" />
Module 4: Interactive Speaking
</div>
</div>
</div>
</div>
</Card>
{/* 2. Attached Practices Section */}
<div className="space-y-6">
<div className="flex items-center justify-between ">
<h2 className="text-[20px] font-bold text-[#0F172A] flex items-center gap-3">
Attached Practices
</h2>
<span className="h-6 px-3 rounded-full bg-grayScale-200/40 text-grayScale-500 text-[12px] font-bold flex items-center justify-center">
Total Items: 3
</span>
</div>
<Card className="overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
{/* Header */}
<div
className="p-6 flex items-center justify-between transition-colors hover:bg-grayScale-25 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-[#FDF2F8] flex items-center justify-center text-[#9E2891]">
<ClipboardList className="h-6 w-6" />
</div>
<div className="space-y-0.5">
<h4 className="text-[17px] font-medium text-grayScale-900">
Multiple Choice
</h4>
<p className="text-[14px] text-grayScale-400 font-medium">
3 Questions ~4 min to complete
</p>
</div>
</div>
<div className="flex items-center gap-6">
<button className="flex items-center gap-2 text-[#9E2891] font-medium text-[14px] hover:opacity-80 transition-opacity">
<Eye className="h-4 w-4" />
Preview
</button>
{isExpanded ? (
<ChevronUp className="h-6 w-6 text-grayScale-300" />
) : (
<ChevronDown className="h-6 w-6 text-grayScale-300" />
)}
</div>
</div>
{/* Expanded Content (Table) */}
{isExpanded && (
<div className="animate-in slide-in-from-top-2 duration-300">
<div className="border-t border-grayScale-100">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-white">
<th className="py-4 pl-10 pr-0 w-[80px]"></th>
<th className="py-4 px-4 text-[13px] font-medium text-[#A5B4C1] uppercase tracking-wide w-24">
Order
</th>
<th className="py-4 px-4 text-[13px] font-medium text-[#A5B4C1] uppercase tracking-wide">
Versions
</th>
</tr>
</thead>
<tbody className="border-t border-grayScale-100">
{questions.map((q, i) => (
<tr
key={i}
className="border-b border-grayScale-200 last:border-0 group hover:bg-grayScale-25 transition-colors"
>
<td className="py-6 pl-10 pr-0 text-center">
<GripVertical className="h-4 w-4 text-[#A5B4C1]" />
</td>
<td className="py-6 px-4">
<span className="text-[15px] text-[#A5B4C1]">
{q.order}
</span>
</td>
<td className="py-6 px-4">
<p className="text-[15px] font-medium text-[#0D1421]">
{q.text}
</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Card>
</div>
{/* 3. Confirmation Checkbox */}
<div className="bg-[#F1F5F9] border border-[#E2E8F0] px-6 py-4 rounded-[12px] flex items-start gap-4">
<input
type="checkbox"
id="confirm"
checked={isConfirmed}
onChange={(e) => setIsConfirmed(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500 cursor-pointer"
/>
<div className="">
<label
htmlFor="confirm"
className="text-[16px] font-bold text-grayScale-900 cursor-pointer"
>
I confirm these details are correct
</label>
<p className="text-[13px] text-grayScale-400">
This action cannot be undone immediately. Rollback requires manual
intervention.
</p>
</div>
</div>
{/* 4. Action Footer */}
<div className="flex items-center justify-between pt-10 px-2">
<Button
onClick={prevStep}
variant="outline"
className="h-12 px-10 rounded-[6px] bg-transparent border-grayScale-400 font-bold text-grayScale-600 transition-all"
>
Back
</Button>
<div className="flex gap-4">
<Button
variant="outline"
className="h-12 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-none transition-all"
>
Save as Draft
</Button>
<Button
onClick={onPublish}
disabled={!isConfirmed}
className={cn(
"h-12 px-10 rounded-[6px] font-bold text-white shadow-xl flex items-center gap-3 transition-all active:scale-95",
isConfirmed
? "bg-[#9E2891] hover:bg-[#8A237E] shadow-[#9E2891]/20"
: "bg-grayScale-200 cursor-not-allowed opacity-50",
)}
>
<Rocket className="h-5 w-5" />
Publish Now
</Button>
</div>
</div>
</div>
);
}
// Add GripVertical helper since it might not be imported from lucide-react if I missed it
function GripVertical({ className }: { className?: string }) {
return (
<div className={cn("grid grid-cols-2 gap-0.5", className)}>
{[...Array(6)].map((_, i) => (
<div key={i} className="h-0.5 w-0.5 rounded-full bg-current" />
))}
</div>
);
}

View File

@ -0,0 +1,168 @@
import { LayoutGrid, Video, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Select } from "../../../../components/ui/select";
import { cn } from "../../../../lib/utils";
interface AttachPracticeStep1Props {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
onCancel: () => void;
}
export function AttachPracticeStep1({
formData,
setFormData,
nextStep,
onCancel,
}: AttachPracticeStep1Props) {
return (
<Card className="overflow-hidden max-w-4xl mx-auto border-grayScale-100 rounded-3xl bg-white shadow-sm animate-in fade-in duration-500">
<div className="space-y-6 p-12">
{/* Select Program */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Select Program
</label>
<div className="relative">
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
value={formData.program}
onChange={(e) =>
setFormData({ ...formData, program: e.target.value })
}
>
<option value="">Choose Program</option>
<option value="skill">Skill-Based Courses</option>
<option value="exams">English Proficiency Exams</option>
</Select>
</div>
</div>
{/* Select Module */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Select Module
</label>
<div className="relative">
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
value={formData.module}
onChange={(e) =>
setFormData({ ...formData, module: e.target.value })
}
>
<option value="">Choose Module</option>
<option value="m1">Module 1: Basic Phrases</option>
<option value="m2">Module 2: Intermediate Grammar</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-1">
Select the specific learning module this practice will reinforce.
</p>
</div>
{/* Select Video */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Select Video
</label>
<div className="relative">
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<Video className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
value={formData.video}
onChange={(e) =>
setFormData({ ...formData, video: e.target.value })
}
>
<option value="">Choose a video</option>
<option value="v1">Intro to Interactive Speaking</option>
<option value="v2">Business Meeting Etiquette</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-1">
Select the specific video this practice will reinforce.
</p>
</div>
{/* Select Question Type */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Select Question Type
</label>
<div className="relative">
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
value={formData.questionType}
onChange={(e) =>
setFormData({ ...formData, questionType: e.target.value })
}
>
<option value="">Choose question type</option>
<option value="speaking">Speaking Practice</option>
<option value="listening">Listening Quiz</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-1">
Select one question type that associates with th selected video
</p>
</div>
{/* Set Version */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Set Version
</label>
<div className="relative">
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
value={formData.version}
onChange={(e) =>
setFormData({ ...formData, version: e.target.value })
}
>
<option value="">Choose versions</option>
<option value="v1">Version 1.0</option>
<option value="v2">Version 2.0</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-1">
Select one or more versions
</p>
</div>
</div>
<div className="flex items-center justify-between border-t border-grayScale-200 bg-[#F8FAFC] py-4 px-12">
<button
className="text-[14px] text-grayScale-500 transition-colors hover:text-grayScale-700"
onClick={onCancel}
>
Cancel
</button>
<Button
onClick={nextStep}
className="h-10 px-12 rounded-[6px] bg-[#9E2891] text-[14px] font-bold text-white shadow-lg shadow-brand-500/10 transition-all active:scale-95 flex items-center gap-3"
>
Next: Review
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</Card>
);
}

View File

@ -0,0 +1,164 @@
import { GraduationCap, ArrowRight, LayoutGrid, Monitor } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Select } from "../../../../components/ui/select";
interface ContextStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
navigate: (path: string) => void;
level: string;
isModuleContext?: boolean;
isCourseContext?: boolean;
}
export function ContextStep({
formData,
setFormData,
nextStep,
navigate,
level,
isModuleContext,
isCourseContext,
}: ContextStepProps) {
return (
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
Step 1: Context Definition
</h2>
<p className="text-grayScale-600 text-base mt-3">
Define the educational level and curriculum module for this practice.
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
<div className="space-y-10 p-10">
{/* Program Field */}
<div className="space-y-3">
<label className="text-[16px] text-grayScale-700 ml-1">
Program{" "}
<span className="text-grayScale-300 font-medium">
(Auto-selected)
</span>
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<GraduationCap className="h-6 w-6 text-grayScale-600" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
disabled
>
<option>{formData.program || "Intermediate"}</option>
</Select>
</div>
</div>
{/* Course Field */}
<div className="space-y-3">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Course{" "}
<span className="text-grayScale-300 font-medium">
(Auto-selected)
</span>
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<GraduationCap className="h-6 w-6 text-grayScale-600" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
disabled
>
<option>{formData.course || "B2"}</option>
</Select>
</div>
</div>
{/* Select Module Field */}
{(isModuleContext || isCourseContext) && (
<div className="space-y-3">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Select Module
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<LayoutGrid className="h-6 w-6 text-grayScale-400" />
</div>
<Select className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all">
<option value="">Choose a module...</option>
<option value="m1">Introduction Basics</option>
<option value="m2">Daily Routines</option>
<option value="m3">Travel Essentials</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-2">
Select the specific learning module this practice will reinforce.
</p>
</div>
)}
{/* Select Video Field (Conditional) */}
{isModuleContext && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Select Video
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<Monitor className="h-6 w-6 text-grayScale-400" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all"
value={formData.selectedVideo}
onChange={(e) =>
setFormData({ ...formData, selectedVideo: e.target.value })
}
>
<option value="">Choose a video</option>
<option value="v1">Intro to Greetings</option>
<option value="v2">Advanced Grammar</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-2">
Select the specific learning module this practice will reinforce.
</p>
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
<button
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
onClick={() =>
navigate(`/new-content/learn-english/${level}/courses`)
}
>
Cancel
</button>
<Button
onClick={nextStep}
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2"
>
Next: {isModuleContext ? "Persona" : "Scenario"}{" "}
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</Card>
);
}

View File

@ -0,0 +1,94 @@
import { Check, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../../../components/ui/avatar";
import { cn } from "../../../../lib/utils";
import { PERSONAS } from "./constants";
interface PersonaStepProps {
selectedPersona: string | null;
setSelectedPersona: (id: string) => void;
nextStep: () => void;
prevStep: () => void;
}
export function PersonaStep({
selectedPersona,
setSelectedPersona,
nextStep,
prevStep,
}: PersonaStepProps) {
return (
<div className="space-y-8">
<div className="space-y-1 px-2">
<h2 className="text-2xl font-extrabold text-grayScale-700">
Select Personas
</h2>
<p className="text-grayScale-400 text-lg">
Choose the characters that will participate in this practice scenario.
</p>
</div>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
{PERSONAS.map((persona) => {
const isSelected = selectedPersona === persona.id;
return (
<div
key={persona.id}
onClick={() => setSelectedPersona(persona.id)}
className={cn(
"group relative w-[260px] cursor-pointer rounded-2xl border-2 bg-white p-6 transition-all duration-300",
isSelected
? "border-brand-500"
: "border-grayScale-100 hover:border-brand-200",
)}
>
{/* Top-right checkmark badge */}
{isSelected && (
<div className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white z-10">
<Check className="h-4 w-4 stroke-[3]" />
</div>
)}
<div className="flex flex-col items-center gap-4">
{/* Avatar with conditional purple ring */}
<div
className={cn(
"rounded-full p-[3px] transition-all duration-300",
isSelected ? "bg-brand-500" : "bg-transparent",
)}
>
<Avatar className="h-24 w-24 border-2 border-white">
<AvatarImage src={persona.avatar} />
<AvatarFallback>
{persona.name.substring(0, 2)}
</AvatarFallback>
</Avatar>
</div>
<span className="text-lg font-bold text-grayScale-700">
{persona.name}
</span>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-8">
<Button
onClick={prevStep}
variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
>
Back
</Button>
<Button
onClick={nextStep}
className="h-10 rounded-[6px] bg-brand-500 px-8 hover:bg-brand-600 shadow-md shadow-brand-500/20"
>
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
import { Edit2, Trash2, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { cn } from "../../../../lib/utils";
interface ProgramAttachReviewStepProps {
formData: any;
prevStep: () => void;
onPublish: () => void;
onCancel: () => void;
}
const MOCK_QUESTIONS = [
{
id: "q1",
title: "1. Speak About The Photo",
description:
'Passage: "Good morning, everyone. I\'d like to start by reviewing the quarterly figures. As you can see from the chart..."',
},
{
id: "q2",
title: "2. Fill In the Blank",
description:
'Passage: "Attention passengers on Flight 492 to London. We are now inviting passengers with small children..."',
},
{
id: "q3",
title: "3. Writing Part 1",
description:
'Passage: "In today\'s lecture on astrophysics, we will discuss the concept of event horizons and their implications..."',
},
];
export function ProgramAttachReviewStep({
formData,
prevStep,
onPublish,
onCancel,
}: ProgramAttachReviewStepProps) {
return (
<div className="space-y-6 animate-in fade-in duration-500">
{/* Questions List */}
<div className="space-y-6">
{MOCK_QUESTIONS.map((q) => (
<Card
key={q.id}
className="group relative flex border-grayScale-200 rounded-2xl bg-white overflow-hidden transition-all"
>
{/* Grip Area */}
<div className="w-[50px] flex items-center justify-center bg-white border-r border-grayScale-200">
<GripVertical className="h-3 w-3 text-grayScale-600" />
</div>
{/* Content Area */}
<div className="flex-1 p-8 py-10">
<h4 className="text-[18px] font-medium text-[#0F172A] mb-3 leading-tight">
{q.title}
</h4>
<p className="text-[14px] text-grayScale-400 leading-relaxed line-clamp-2">
{q.description}
</p>
</div>
{/* Actions Area (Vertical Stack) */}
<div className="w-[50px] border-l border-grayScale-200 flex flex-col items-center justify-center divide-y divide-grayScale-50">
<button className="flex-1 w-full flex items-center justify-center text-grayScale-600 hover:text-brand-500 transition-colors border-b border-grayScale-200">
<Edit2 className="h-4 w-4" />
</button>
<button className="flex-1 w-full flex items-center justify-center text-grayScale-600 hover:text-red-500 transition-colors">
<Trash2 className="h-4 w-4" />
</button>
</div>
</Card>
))}
</div>
{/* Footer Actions */}
<div className="flex bg-[#F8FAFC] border border-grayScale-200 items-center rounded-[12px] justify-between py-4 px-6">
<Button
onClick={onCancel}
variant="outline"
className="h-10 px-6 rounded-[6px] border-grayScale-100 font-bold text-grayScale-500 bg-white hover:bg-grayScale-50 transition-all text-lg"
>
Cancel
</Button>
<Button
onClick={onPublish}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] text-[16px] font-medium text-white transition-all active:scale-95 flex items-center gap-3"
>
Next: Review & Publish
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
// GripVertical helper
function GripVertical({ className }: { className?: string }) {
return (
<div className={cn("grid grid-cols-2 gap-0.5", className)}>
{[...Array(6)].map((_, i) => (
<div key={i} className="h-1 w-1 rounded-full bg-current" />
))}
</div>
);
}

View File

@ -0,0 +1,141 @@
import { LayoutGrid, Plus, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Select } from "../../../../components/ui/select";
import { cn } from "../../../../lib/utils";
interface ProgramAttachStep1Props {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
onCancel: () => void;
}
export function ProgramAttachStep1({
formData,
setFormData,
nextStep,
onCancel,
}: ProgramAttachStep1Props) {
return (
<Card className="overflow-hidden border-grayScale-100 rounded-[16px] bg-white shadow-sm animate-in fade-in duration-500">
<div className="space-y-6 p-8 pb-16">
{/* Select Program */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Select Program
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
value={formData.program}
onChange={(e) =>
setFormData({ ...formData, program: e.target.value })
}
>
<option value="">Choose Program</option>
<option value="exams">English Proficiency Exams</option>
<option value="skill">Skill-Based Courses</option>
</Select>
</div>
</div>
{/* Tests (Auto Select) */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Tests{" "}
<span className="text-grayScale-400 font-medium">
(Auto Select)
</span>
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<div className="h-[56px] w-full rounded-[12px] border border-grayScale-200 bg-white flex items-center pl-16 text-grayScale-700 font-medium text-sm">
Mock Exam 1
</div>
</div>
</div>
<div className="w-full border-t border-grayScale-300" />
{/* Select Question Type */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Select Question Type
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
value={formData.questionType}
onChange={(e) =>
setFormData({ ...formData, questionType: e.target.value })
}
>
<option value="">Choose question type</option>
<option value="Speaking Practice">Speaking Practice</option>
<option value="Writing Part 1">Writing Part 1</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-1">
Select one question type that associates with th selected video
</p>
</div>
{/* Set Version */}
<div className="space-y-4">
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
Set Version
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
</div>
<Select
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
value={formData.version}
onChange={(e) =>
setFormData({ ...formData, version: e.target.value })
}
>
<option value="">Choose a version</option>
<option value="V 1.0">V 1.0</option>
<option value="V 2.0">V 2.0</option>
</Select>
</div>
</div>
{/* Add More Button */}
<button className="flex items-center gap-2 text-[#9E2891] font-medium text-[14px] group transition-all hover:translate-x-1">
<div className="h-5 w-5 rounded-full border-2 border-[#9E2891] flex items-center justify-center">
<Plus className="h-3 w-3" />
</div>
Add More
</button>
</div>
<div className="flex items-center justify-between border-t border-grayScale-200 bg-[#F8FAFC] py-4 px-12">
<button
className="text-[14px] text-grayScale-500 transition-colors hover:text-grayScale-700"
onClick={onCancel}
>
Cancel
</button>
<Button
onClick={nextStep}
className="h-10 px-12 rounded-[6px] bg-[#9E2891] text-[14px] font-bold text-white shadow-lg shadow-brand-500/10 transition-all active:scale-95 flex items-center gap-3"
>
Next: Review
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</Card>
);
}

View File

@ -0,0 +1,156 @@
import { GripVertical, Trash2, Plus, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import { VoicePrompt } from "./VoicePrompt";
interface QuestionsStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
prevStep: () => void;
}
export function QuestionsStep({
formData,
setFormData,
nextStep,
prevStep,
}: QuestionsStepProps) {
const addQuestion = () => {
const newQuestion = {
id: `q${formData.questions.length + 1}`,
text: "",
type: "Speaking",
voicePrompt: "upload_audio.mp3",
sampleAnswer: "upload_audio.mp3",
};
setFormData({
...formData,
questions: [...formData.questions, newQuestion],
});
};
return (
<div className="space-y-6">
<div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700">
Create Practice Questions
</h2>
<p className="text-grayScale-400 text-lg">
Define the dialogue flow and interactions for this scenario.
</p>
</div>
<div className="space-y-6">
{formData.questions.map((q: any, i: number) => (
<Card
key={q.id}
className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative"
>
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
<div className="px-5 pb-7 pt-2 space-y-6">
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
<span className="font-bold text-grayScale-500 text-base">
Question {i + 1}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="text-brand-500 hover:bg-brand-50 rounded-lg"
onClick={() => {
const newQuestions = formData.questions.filter(
(item: any) => item.id !== q.id,
);
setFormData({ ...formData, questions: newQuestions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
<div className="md:col-span-8 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
QUESTION PROMPT
</label>
<Input
value={q.text}
onChange={(e) => {
const newQuestions = [...formData.questions];
newQuestions[i].text = e.target.value;
setFormData({ ...formData, questions: newQuestions });
}}
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
placeholder="e.g. How long have you been studying English?"
/>
</div>
<div className="md:col-span-4 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
VOICE PROMPT
</label>
<VoicePrompt
src={q.voicePrompt}
filename={q.voicePrompt}
onRemove={() => {
const newQuestions = [...formData.questions];
newQuestions[i].voicePrompt = "";
setFormData({ ...formData, questions: newQuestions });
}}
/>
</div>
</div>
<div className="md:w-1/3 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
SAMPLE ANSWER PROMPT
</label>
<VoicePrompt
src={q.sampleAnswer}
filename={q.sampleAnswer}
onRemove={() => {
const newQuestions = [...formData.questions];
newQuestions[i].sampleAnswer = "";
setFormData({ ...formData, questions: newQuestions });
}}
/>
</div>
</div>
</Card>
))}
<div className="flex items-center gap-8 pt-4">
<button
onClick={addQuestion}
className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all"
>
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" />
</div>{" "}
Add New Question
</button>
<button className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all">
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" />
</div>{" "}
Add Tips
</button>
</div>
</div>
<div className="flex items-center justify-between pt-8">
<Button
onClick={prevStep}
variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm"
>
Back
</Button>
<Button
onClick={nextStep}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold "
>
Next: Review <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,305 @@
import { Edit2, GripVertical, Trash2, Rocket, Info } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../../../components/ui/avatar";
import { PERSONAS } from "./constants";
import { VoicePrompt } from "./VoicePrompt";
interface ReviewStepProps {
formData: any;
selectedPersona: string | null;
prevStep: () => void;
setIsPublished: (val: boolean) => void;
isModuleContext?: boolean;
}
export function ReviewStep({
formData,
selectedPersona,
prevStep,
setIsPublished,
isModuleContext,
}: ReviewStepProps) {
const persona = PERSONAS.find((p) => p.id === selectedPersona);
return (
<div className="space-y-10 animate-in fade-in duration-700">
<div className="flex items-center justify-between px-2">
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
Review Practice Questions
</h2>
</div>
{/* 1. Basic Info Card (Image 1436.1) */}
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white ">
<div className="border-b border-grayScale-50 p-4 px-5 flex justify-between items-center bg-white">
<h3 className="text-[17px] font-extrabold text-grayScale-900">
Basic Information
</h3>
<Button
variant="ghost"
size="sm"
className="text-brand-500 font-bold hover:bg-brand-50 gap-2 h-9"
>
<Edit2 className="h-4 w-4" />
Edit
</Button>
</div>
{/* Gradient Divider */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-100" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
<div className="p-8 px-5 flex items-center justify-between ">
<div className="flex items-center gap-6">
<div className="h-[70px] w-[85px] rounded-xl bg-grayScale-100 overflow-hidden shadow-inner flex-shrink-0">
<img
src="https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
alt="Banner"
className="w-full h-full object-cover opacity-80"
/>
</div>
<div className="space-y-2">
<h4 className="text-[22px] font-bold text-grayScale-900 leading-tight">
{formData.title || "Business English 101: Communication"}
</h4>
<div className="flex items-center gap-6 text-[14px]">
<span className="text-grayScale-900 ">
Program:{" "}
<span className="text-brand-500 ">{formData.program}</span>
</span>
<span className="text-grayScale-900 ">
Course:{" "}
<span className="text-brand-500 ">{formData.course}</span>
</span>
<span className="text-grayScale-900 font-bold">
Module:{" "}
<span className="text-brand-500 font-extrabold">
Module 101
</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] text-left font-medium text-grayScale-900 ">
Persona
</span>
<div className="flex items-center gap-2 bg-[#FAF5FF] py-1 pl-2.5 pr-4 rounded-full border border-brand-100/30">
<Avatar className="h-8 w-8 border-2 border-white shadow-sm font-bold">
<AvatarImage src={persona?.avatar} />
<AvatarFallback>P</AvatarFallback>
</Avatar>
<span className="text-[14px] text-brand-500 capitalize">
{persona?.name || "Alex Johnson"}
</span>
</div>
</div>
</div>
</Card>
{/* 2. Tips Section (Image 1436.1) */}
<div className="space-y-4 px-2">
<div className="flex items-center gap-2">
<label className="text-[12px] font-bold text-grayScale-900 uppercase tracking-widest leading-none">
TIPS / GUIDANCE
</label>
<Info className="h-4 w-4 text-brand-500" />
</div>
<div className="px-5 pt-2 pb-8 bg-white border border-[#E2E8F0] shadow-sm rounded-xl">
<p className="text-[14px] text-grayScale-500 font-medium leading-relaxed">
{formData.tips ||
"Focus on using the present perfect continuous tense to describe an action that started in the past and continues now."}
</p>
</div>
</div>
{isModuleContext ? (
/* 3. Split Questions & Answers Layout (Image 1413.1) */
<div className="grid grid-cols-1 md:grid-cols-2 bg-white rounded-[12px] border border-grayScale-50 shadow-sm overflow-hidden min-h-[600px]">
{/* Left Column: Questions */}
<div className="border-r border-grayScale-200 flex flex-col">
<div className="p-4 border-b border-grayScale-50 flex items-center gap-3 bg-white">
<h3 className="text-[16px] font-extrabold text-[#0F172A]">
Questions
</h3>
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
{formData.questions.length}
</span>
</div>
<div className="p-4 space-y-14">
{formData.questions.map((q: any, i: number) => (
<div key={q.id} className="relative pl-12">
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
{(i + 1).toString().padStart(2, "0")}
</span>
<div className="space-y-8">
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
TEXT PROMPT
</span>
<p className="text-[16px] font-medium text-grayScale-600 leading-relaxed max-w-[90%]">
{q.text}
</p>
</div>
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-300 uppercase tracking-[0.1em] block">
VOICE PROMPT
</span>
<VoicePrompt
filename={q.voicePrompt}
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[72px]"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Right Column: Answers */}
<div className="flex flex-col">
<div className="p-4 border-b border-grayScale-50 flex items-center justify-between bg-white">
<div className="flex items-center gap-3">
<h3 className="text-[16px] font-extrabold ">Answers</h3>
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
{formData.questions.length}
</span>
</div>
<button className="flex items-center gap-2 text-brand-500 font-bold text-[15px] hover:opacity-80 transition-opacity">
<Edit2 className="h-3 w-3" />
Edit
</button>
</div>
<div className="p-4 space-y-14">
{formData.questions.map((q: any, i: number) => (
<div key={q.id + "_ans"} className="relative pl-12">
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
{(i + 1).toString().padStart(2, "0")}
</span>
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
VOICE PROMPT
</span>
<VoicePrompt
filename={q.sampleAnswer}
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[60px]"
/>
</div>
</div>
))}
</div>
</div>
</div>
) : (
/* Original Non-Module View */
<div className="space-y-6">
{formData.questions.map((q: any, i: number) => (
<ReviewItem key={q.id} q={q} index={i} />
))}
</div>
)}
{/* Action Footer */}
<div className="flex items-center justify-between pt-12">
<Button
onClick={prevStep}
variant="outline"
className="h-10 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
>
Back
</Button>
<div className="flex gap-4">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
>
Save as Draft
</Button>
<Button
onClick={() => setIsPublished(true)}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold hover:bg-brand-600 shadow-xl shadow-brand-500/20 gap-3 active:scale-95 transition-all text-white text-sm"
>
<Rocket className="h-4 w-4" />
Publish Now
</Button>
</div>
</div>
</div>
);
}
function ReviewItem({ q, index }: { q: any; index: number }) {
return (
<Card className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative">
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
<div className="px-5 pb-7 pt-2 space-y-6">
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
<span className="font-bold text-grayScale-500 text-base">
Question {index + 1}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="text-brand-500 hover:bg-brand-50 rounded-lg"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
<div className="md:col-span-8 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
QUESTION PROMPT
</label>
<Input
value={q.text}
readOnly
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
placeholder="e.g. How long have you been studying English?"
/>
</div>
<div className="md:col-span-4 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
VOICE PROMPT
</label>
<VoicePrompt
src={q.voicePrompt}
filename={q.voicePrompt}
onRemove={() => {}}
/>
</div>
</div>
<div className="md:w-1/3 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
SAMPLE ANSWER PROMPT
</label>
<VoicePrompt
src={q.sampleAnswer}
filename={q.sampleAnswer}
onRemove={() => {}}
/>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,118 @@
import { Upload, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
interface ScenarioStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
prevStep: () => void;
}
export function ScenarioStep({
formData,
setFormData,
nextStep,
prevStep,
}: ScenarioStepProps) {
return (
<div className="space-y-6">
<div className="space-y-1 px-2">
<h2 className="text-2xl font-extrabold text-grayScale-700">
Define Scenario Details
</h2>
<p className="text-grayScale-400 text-lg">
Set the scene and context for this English practice session.
</p>
</div>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-1">
<label className="text-sm text-grayScale-700">
Practice Banner Image
</label>
<p className="text-xs pb-2 text-grayScale-400">
This image will appear as the background for the scenario.
</p>
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-grayScale-200 bg-[#F8F9FA] p-12 hover:bg-grayScale-50 transition-all">
<div className="mb-4 rounded-xl border border-grayScale-100 bg-white p-3 text-brand-500 shadow-sm">
<Upload className="h-6 w-6" />
</div>
<p className="text-sm">
<span className="text-grayScale-700">
Click to upload or drag and drop
</span>
</p>
<p className="mt-1 text-xs text-grayScale-400 uppercase tracking-wide ">
SVG, PNG, JPG (MAX 5MB)
</p>
<Button
variant="outline"
className="mt-6 h-10 rounded-[6px] border-grayScale-200 bg-white px-8 font-bold text-brand-500 shadow-sm hover:bg-grayScale-50"
>
Browse Files
</Button>
</div>
</div>
</Card>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Practice Title <span className="text-red-500">*</span>
</label>
<Input
placeholder="e.g., Ordering Coffee at a Cafe"
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Scenario Description <span className="text-red-500">*</span>
</label>
<div className="relative">
<Textarea
placeholder="Describe the setting..."
className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white"
maxLength={1000}
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
/>
<div className="absolute bottom-4 right-4 text-xs font-bold text-grayScale-500">
{formData.description.length} / 1000
</div>
</div>
<span className="text-xs text-grayScale-500">
Provide context for the AI and the student. Be specific about the
location and the goal.
</span>
</div>
</Card>
<div className="flex items-center justify-between pt-4">
<Button
onClick={prevStep}
variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600 shadow-sm"
>
Back
</Button>
<Button
onClick={nextStep}
disabled={!formData.title || !formData.description}
className="h-10 rounded-[6px] bg-brand-500 px-8 "
>
Next: Persona <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from "react";
import { Play, Pause, X } from "lucide-react";
import { cn } from "../../../../lib/utils";
interface VoicePromptProps {
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
src?: string;
filename: string;
onRemove?: () => void;
className?: string;
}
const BAR_COUNT = 24;
export function VoicePrompt({
src,
filename,
onRemove,
className,
}: VoicePromptProps) {
const [bars, setBars] = useState<number[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); // 01
const audioRef = useRef<HTMLAudioElement | null>(null);
const rafRef = useRef<number | null>(null);
// ─── Decode audio and build waveform bars ───────────────────────────────────
useEffect(() => {
if (!src) {
// No real audio — generate plausible static bars
setBars(generateFakeBars());
return;
}
let cancelled = false;
const audioCtx = new AudioContext();
fetch(src)
.then((r) => r.arrayBuffer())
.then((buf) => audioCtx.decodeAudioData(buf))
.then((decoded) => {
if (cancelled) return;
const raw = decoded.getChannelData(0);
const blockSize = Math.floor(raw.length / BAR_COUNT);
const barsData = Array.from({ length: BAR_COUNT }, (_, i) => {
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(raw[i * blockSize + j]);
}
return sum / blockSize;
});
// Normalize to 01
const max = Math.max(...barsData, 0.001);
setBars(barsData.map((v) => v / max));
})
.catch(() => {
if (!cancelled) setBars(generateFakeBars());
})
.finally(() => audioCtx.close());
return () => {
cancelled = true;
};
}, [src]);
// ─── Sync progress while playing ────────────────────────────────────────────
const startProgressLoop = () => {
const tick = () => {
const el = audioRef.current;
if (!el) return;
setProgress(el.currentTime / (el.duration || 1));
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
};
const stopProgressLoop = () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
// ─── Play / Pause ────────────────────────────────────────────────────────────
const handlePlayPause = () => {
if (!src) return;
if (!audioRef.current) {
audioRef.current = new Audio(src);
audioRef.current.onended = () => {
setIsPlaying(false);
setProgress(0);
stopProgressLoop();
};
}
if (isPlaying) {
audioRef.current.pause();
stopProgressLoop();
setIsPlaying(false);
} else {
audioRef.current.play().then(() => {
setIsPlaying(true);
startProgressLoop();
});
}
};
// ─── Cleanup on unmount ──────────────────────────────────────────────────────
useEffect(() => {
return () => {
stopProgressLoop();
audioRef.current?.pause();
};
}, []);
const playedBars = Math.round(progress * BAR_COUNT);
return (
<div
className={cn(
"flex items-center gap-4 px-4 py-3 bg-[#F9F0FB] rounded-6px border border-brand-100 min-h-[60px]",
className,
)}
>
{/* Play / Pause button */}
<button
onClick={handlePlayPause}
className="h-8 w-8 flex-shrink-0 rounded-full bg-brand-500 flex items-center justify-center text-white shadow-md hover:bg-brand-600 transition-colors"
>
{isPlaying ? (
<Pause className="h-3 w-3 fill-current" />
) : (
<Play className="h-3 w-3 fill-current ml-0.5" />
)}
</button>
{/* Waveform + filename */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
{/* Bars — centered vertically, grow up and down */}
<div className="flex items-center gap-[3.5px] h-6 overflow-hidden">
{(bars.length ? bars : generateFakeBars()).map((v, i) => (
<div
key={i}
className="w-[4px] rounded-full flex-shrink-0"
style={{
height: `${Math.max(v * 100, 14)}%`,
backgroundColor: i < playedBars ? "#9E2891" : "#D5C5DC",
}}
/>
))}
</div>
{/* Filename */}
<p className="text-[11px] font-semibold text-brand-500 truncate">
{filename}
</p>
</div>
{/* Remove button */}
{onRemove && (
<button
onClick={onRemove}
className="flex-shrink-0 text-brand-500 hover:text-brand-700 transition-colors p-1"
aria-label="Remove"
>
<X className="h-5 w-5" />
</button>
)}
</div>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function generateFakeBars(): number[] {
// Realistic-looking static waveform (peaks in middle, quieter at edges)
return Array.from({ length: BAR_COUNT }, (_, i) => {
const center = BAR_COUNT / 2;
const envelope = 1 - Math.abs(i - center) / center;
return Math.max(0.1, envelope * (0.4 + Math.random() * 0.6));
});
}

View File

@ -0,0 +1,44 @@
export const PERSONAS = [
{
id: "dawit",
name: "Dawit",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
},
{
id: "mahlet",
name: "Mahlet",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
},
{
id: "amanuel",
name: "Amanuel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
},
{
id: "bethel",
name: "Bethel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
},
{
id: "liya",
name: "Liya",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
},
{
id: "aseffa",
name: "Aseffa",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
},
{
id: "hana",
name: "Hana",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
},
{
id: "nahom",
name: "Nahom",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
},
];
export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"];

View File

@ -0,0 +1,150 @@
import { useState } from "react";
import { X, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Select } from "../../../../components/ui/select";
import { Badge } from "../../../../components/ui/badge";
interface QuestionTypeBasicInfoStepProps {
onNext: () => void;
}
export function QuestionTypeBasicInfoStep({
onNext,
}: QuestionTypeBasicInfoStepProps) {
const [selectedChips, setSelectedChips] = useState([
"Multiple Choice",
"Sentence Completion",
]);
const suggestions = ["Matching Headings", "True/False/NG"];
const removeChip = (chip: string) => {
setSelectedChips(selectedChips.filter((c) => c !== chip));
};
const addChip = (chip: string) => {
if (!selectedChips.includes(chip)) {
setSelectedChips([...selectedChips, chip]);
}
};
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">
STEP 1: Basic Info
</h2>
<p className="text-grayScale-500 font-medium mt-1">
Define what this question type is and where it applies.
</p>
</div>
<div className="p-10 space-y-10">
{/* Top Row: Course Type & Skill Category */}
<div className="grid grid-cols-2 gap-10">
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Course Type <span className="text-red-500">*</span>
</label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
<option>Select an exam type</option>
<option>IELTS</option>
<option>Duolingo</option>
<option>TOEFL</option>
</Select>
<p className="text-grayScale-400 text-[13px] font-medium leading-relaxed">
The core framework for the practice test.
</p>
</div>
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Skill Category <span className="text-red-500">*</span>
</label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
<option>Select a skill</option>
<option>Speaking</option>
<option>Writing</option>
<option>Listening</option>
<option>Reading</option>
</Select>
</div>
</div>
{/* Question Type */}
<div className="space-y-3">
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
Question Type <span className="text-red-500">*</span>
</label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
<option>Single Format</option>
<option>Mixed Format</option>
</Select>
</div>
{/* Question Types Chip Input */}
<div className="space-y-3">
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
Question Types <span className="text-red-500">*</span>
</label>
<div className="min-h-[56px] p-3 flex flex-wrap gap-2.5 rounded-[12px] border border-grayScale-300 bg-[#F8FAFC]">
{selectedChips.map((chip) => (
<Badge
key={chip}
className="bg-[#9E28911A] text-[#9E2891] border-[#9E289133] px-2 py-0 rounded-full text-[13px] font-medium flex items-center gap-2"
>
{chip}
<button
onClick={() => removeChip(chip)}
className="hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
<input
className="flex-1 min-w-[150px] bg-transparent border-none focus:ring-0 text-[14px] font-medium text-grayScale-900 px-3 placeholder:text-grayScale-400"
placeholder="Add question types..."
/>
</div>
<div className="flex items-center gap-3 pt-1">
<span className="text-[13px] font-medium text-grayScale-500">
Suggestions:
</span>
<div className="flex items-center gap-2">
{suggestions.map((s) => (
<button
key={s}
onClick={() => addChip(s)}
className="px-3 py-1.5 rounded-[6px] border border-grayScale-300 text-[13px] font-medium text-grayScale-600 hover:bg-grayScale-50 hover:text-[#9E2891] hover:border-[#9E2891]/20 transition-all"
>
{s}
</button>
))}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
>
Cancel
</Button>
<Button
onClick={onNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
>
Next: Structure
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,249 @@
import { useEffect, useMemo, useState } from "react";
import { Rocket, Edit2, Link2, Video } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { toast } from "sonner";
import type { AddLessonFormData } from "../../AddVideoFlow";
import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
getVideoPreview,
resolveThumbnailForPreview,
} from "../../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
interface ReviewPublishStepProps {
formData: AddLessonFormData;
prevStep: () => void;
onPublish: () => void;
publishing: boolean;
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return `${s.slice(0, max)}`;
}
export function ReviewPublishStep({
formData,
prevStep,
onPublish,
publishing,
}: ReviewPublishStepProps) {
const [thumbBroken, setThumbBroken] = useState(false);
const videoPreview = useMemo(
() => getVideoPreview(formData.videoUrl),
[formData.videoUrl],
);
const limitedEmbedSrc = useMemo(() => {
if (videoPreview.kind !== "iframe") return null;
return applyShortPreviewToEmbedUrl(
videoPreview.src,
videoPreview.label,
DEFAULT_PREVIEW_MAX_SECONDS,
);
}, [videoPreview]);
const previewLengthLabel = formatPreviewLength(DEFAULT_PREVIEW_MAX_SECONDS);
const thumbSrc = useMemo(
() => resolveThumbnailForPreview(formData.thumbnailUrl),
[formData.thumbnailUrl],
);
useEffect(() => {
setThumbBroken(false);
}, [thumbSrc]);
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
<div className="px-8 py-5 border-b border-grayScale-50 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between bg-white">
<h3 className="text-[17px] font-bold text-grayScale-900">
Media preview
</h3>
<p className="text-xs font-medium text-grayScale-500">
Video: short clip (first {previewLengthLabel} only)
</p>
</div>
<div className="p-8">
<div className="flex flex-col gap-10 xl:flex-row xl:items-start xl:gap-10">
{/* Video preview */}
<div className="min-w-0 flex-1 space-y-3">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Video
</span>
{formData.videoUrl ? (
<div className="space-y-3">
{videoPreview.kind === "iframe" && limitedEmbedSrc ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
<div className="relative aspect-video w-full max-w-4xl">
<iframe
key={limitedEmbedSrc}
src={limitedEmbedSrc}
title={`${videoPreview.label} lesson preview`}
className="absolute inset-0 h-full w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 py-2.5 text-center text-[11px] font-semibold text-white/95">
Short clip · max {previewLengthLabel}
</div>
</div>
</div>
) : videoPreview.kind === "video" ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-black shadow-sm">
<PreviewLimitedFileVideo
src={videoPreview.src}
maxSeconds={DEFAULT_PREVIEW_MAX_SECONDS}
/>
</div>
) : (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-grayScale-200 bg-grayScale-50/80 px-6 py-10 text-center">
<Video className="h-10 w-10 text-grayScale-300" />
<p className="text-sm font-medium text-grayScale-600">
No inline preview for this URL
</p>
<p className="text-xs text-grayScale-500 max-w-md">
Use a Vimeo, YouTube, or direct link to a video file
(MP4, WebM, ) to see a player here. The URL below will
still be saved.
</p>
</div>
)}
<div className="flex items-start gap-2 text-[13px] text-grayScale-600 break-all">
<Link2 className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-grayScale-400" />
{truncate(formData.videoUrl, 220)}
</div>
</div>
) : (
<p className="text-grayScale-400 text-sm"></p>
)}
</div>
{/* Thumbnail preview */}
<div className="w-full shrink-0 space-y-3 xl:max-w-[360px]">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Thumbnail
</span>
{formData.thumbnailUrl && thumbSrc ? (
<div className="space-y-3">
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-100 shadow-sm">
<div className="relative aspect-video w-full max-w-md">
{!thumbBroken ? (
<img
src={thumbSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbBroken(true)}
/>
) : (
<div className="flex aspect-video w-full max-w-md items-center justify-center bg-grayScale-200 px-4 text-center text-xs text-grayScale-500">
Thumbnail could not be loaded. URL will still be
saved.
</div>
)}
</div>
</div>
<p className="text-[12px] text-grayScale-500 break-all">
{truncate(formData.thumbnailUrl, 160)}
</p>
</div>
) : (
<p className="text-grayScale-400 text-sm"></p>
)}
</div>
</div>
</div>
</div>
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
<h3 className="text-[16px] font-bold text-grayScale-900">
Content details
</h3>
<button
type="button"
onClick={prevStep}
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
>
<Edit2 className="h-3 w-3" />
Edit
</button>
</div>
<div className="p-8 space-y-10">
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Title
</span>
<p className="text-[15px] font-medium text-grayScale-900">
{formData.title || "—"}
</p>
</div>
<div className="space-y-3">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Description
</span>
<div
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
dangerouslySetInnerHTML={{
__html:
formData.description || "<p class='text-grayScale-400'>—</p>",
}}
/>
</div>
</div>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{ background: "gray" }}
/>
</div>
</div>
<div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={publishing}
className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
>
Back
</Button>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
disabled={publishing}
onClick={() =>
toast.info("Drafts are not supported yet. Use Create lesson.")
}
>
Save as draft
</Button>
<Button
type="button"
onClick={onPublish}
disabled={publishing}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
>
<Rocket className="h-4 w-4" />
{publishing ? "Creating…" : "Create lesson"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,249 @@
import {
useRef,
useEffect,
type Dispatch,
type SetStateAction,
} from "react";
import { List, Link as LinkIcon, Lightbulb, ArrowRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../../../components/ui/button";
import { Input } from "../../../../components/ui/input";
import type { AddLessonFormData } from "../../AddVideoFlow";
import { LessonMediaUploadField } from "../LessonMediaUploadField";
function isDescriptionEmpty(raw: string): boolean {
if (!raw?.trim()) return true;
const t = raw.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim();
return t.length === 0;
}
interface VideoDetailStepProps {
formData: AddLessonFormData;
setFormData: Dispatch<SetStateAction<AddLessonFormData>>;
onContinue: () => void;
}
export function VideoDetailStep({
formData,
setFormData,
onContinue,
}: VideoDetailStepProps) {
const editorRef = useRef<HTMLDivElement>(null);
const isInternalChange = useRef(false);
useEffect(() => {
if (editorRef.current && !isInternalChange.current) {
editorRef.current.innerHTML = formData.description || "";
}
}, []);
const handleCommand = (command: string, value?: string) => {
document.execCommand(command, false, value);
syncState();
};
const syncState = () => {
if (editorRef.current) {
isInternalChange.current = true;
setFormData((prev) => ({
...prev,
description: editorRef.current!.innerHTML,
}));
setTimeout(() => {
isInternalChange.current = false;
}, 0);
}
};
const handleInput = () => {
syncState();
};
const handleContinue = () => {
if (editorRef.current) {
setFormData((prev) => ({
...prev,
description: editorRef.current!.innerHTML,
}));
}
if (!formData.title.trim()) {
toast.error("Title is required");
return;
}
if (!formData.videoUrl.trim()) {
toast.error("Add a video URL or upload a video");
return;
}
if (!formData.thumbnailUrl.trim()) {
toast.error("Add a thumbnail or upload an image");
return;
}
const descHtml = editorRef.current?.innerHTML ?? formData.description;
if (isDescriptionEmpty(descHtml)) {
toast.error("Description is required");
return;
}
onContinue();
};
return (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20">
<div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8">
<div className="space-y-3">
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
Video
</h3>
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
sent to your storage via{" "}
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
POST /files/upload
</code>
.
</p>
<LessonMediaUploadField
kind="video"
value={formData.videoUrl}
onChange={(v) =>
setFormData((prev) => ({ ...prev, videoUrl: v }))
}
/>
</div>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{ background: "gray" }}
/>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-12 items-start">
<div className="flex-1 w-full space-y-10">
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Lesson title
</label>
<Input
placeholder="e.g. Introduction to Past Tense"
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({ ...prev, title: e.target.value }))
}
/>
</div>
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Description
</label>
<div className="rounded-xl border border-grayScale-200 bg-white overflow-hidden flex flex-col min-h-[200px] shadow-sm focus-within:border-brand-200 transition-all">
<div className="flex items-center gap-1 bg-[#F8FAFC]">
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
<button
type="button"
onClick={() => handleCommand("bold")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif font-bold text-[17px] pb-0.5 active:bg-grayScale-50"
>
B
</button>
<button
type="button"
onClick={() => handleCommand("italic")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif italic text-[17px] pr-0.5 active:bg-grayScale-50"
>
I
</button>
<button
type="button"
onClick={() => handleCommand("insertUnorderedList")}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
>
<List className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => {
const url = prompt("Enter URL:");
if (url) handleCommand("createLink", url);
}}
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
>
<LinkIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="relative p-6 flex-1">
{isDescriptionEmpty(formData.description) && (
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
What will students learn in this lesson?
</div>
)}
<div
ref={editorRef}
contentEditable
onInput={handleInput}
className="w-full min-h-[140px] focus:outline-none text-[15px] text-grayScale-700 font-medium leading-relaxed prose prose-sm max-w-none"
/>
</div>
</div>
</div>
</div>
<div className="w-full lg:w-[360px] space-y-5">
<LessonMediaUploadField
kind="thumbnail"
value={formData.thumbnailUrl}
onChange={(v) =>
setFormData((prev) => ({ ...prev, thumbnailUrl: v }))
}
/>
<div className="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
<Lightbulb
className="h-4 w-4 text-brand-50"
fill="#A855F7"
/>
</div>
</div>
<div className="relative top-[-10px]">
<h3 className="text-[14px] font-bold text-grayScale-900">
Pro tip
</h3>
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
Use clear titles and a thumbnail that matches the lesson. The
lesson is created with{" "}
<code className="rounded bg-white/80 px-1 text-[10px]">
POST /modules/:moduleId/lessons
</code>{" "}
when you publish.
</p>
</div>
</div>
</div>
</div>
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-end">
<Button
type="button"
onClick={handleContinue}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2 text-sm group active:scale-95"
>
Continue
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

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

View File

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

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

View File

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

View File

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