diff --git a/package-lock.json b/package-lock.json index 0fb2f56..3a96594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index eba2940..57f998e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index ec88619..c93de0f 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -47,20 +47,8 @@ import type { GetSubCoursePrerequisitesResponse, AddSubCoursePrerequisiteRequest, GetLearningPathResponse, - GetSubModuleLessonDetailResponse, GetHumanLanguageLessonsResponse, - GetSubModuleLessonsResponse, - GetHumanLanguageSubCategoriesResponse, - GetCategorySubCategoriesResponse, - GetSubCategoryCoursesResponse, - GetCourseLevelsForCourseResponse, - GetCourseLevelsAllResponse, - GetCourseLevelByIdResponse, - GetHumanLanguageHierarchyFlatResponse, - GetCourseHierarchyResponse, - GetSubModulesByModuleResponse, - CourseHierarchyRow, - SubCourse, + GetHumanLanguageHierarchyResponse, CreateHumanLanguageLessonRequest, GetSubCourseEntryAssessmentResponse, ReorderItem, @@ -68,8 +56,6 @@ import type { GetRatingsParams, GetVimeoSampleResponse, CreateCourseVideoRequest, - UpdateSubModuleLessonRequest, - UpdateSubModuleLessonResponse, } from "../types/course.types" type UnifiedHierarchyRow = { @@ -81,22 +67,21 @@ type UnifiedHierarchyRow = { course_title?: string | null } -async function withSingleRetry(request: () => Promise, retryDelayMs = 400): Promise { - try { - return await request() - } catch { - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) - return request() - } +type CourseHierarchyRow = { + course_id: number + course_title: string + level_id?: number | null + cefr_level?: string | null + module_id?: number | null + module_title?: string | null + sub_module_id?: number | null + sub_module_title?: string | null } export const getCourseCategories = () => - withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => { + http.get("/course-management/hierarchy").then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] - const categoriesMap = new Map< - number, - { id: number; name: string; is_active: boolean; created_at: string; subCategoryCount: number; courseCount: number } - >() + const categoriesMap = new Map() rows.forEach((r) => { if (!categoriesMap.has(r.category_id)) { categoriesMap.set(r.category_id, { @@ -104,50 +89,10 @@ export const getCourseCategories = () => name: r.category_name, is_active: true, created_at: new Date().toISOString(), - subCategoryCount: 0, - courseCount: 0, }) } - const category = categoriesMap.get(r.category_id)! - if (r.sub_category_id) category.subCategoryCount += 1 - if (r.course_id) category.courseCount += 1 }) - - // Merge duplicate top-level category names by selecting the richest representative. - type CategoryAggregate = { - id: number - name: string - is_active: boolean - created_at: string - subCategoryCount: number - courseCount: number - } - const categoryByName = new Map() - Array.from(categoriesMap.values()).forEach((category) => { - const key = category.name.trim().toLowerCase() - const existing = categoryByName.get(key) - if (!existing) { - categoryByName.set(key, category) - return - } - if (category.subCategoryCount > existing.subCategoryCount) { - categoryByName.set(key, category) - return - } - if (category.subCategoryCount === existing.subCategoryCount && category.courseCount > existing.courseCount) { - categoryByName.set(key, category) - return - } - if ( - category.subCategoryCount === existing.subCategoryCount && - category.courseCount === existing.courseCount && - category.id > existing.id - ) { - categoryByName.set(key, category) - } - }) - - const categories = Array.from(categoryByName.values()).map(({ subCategoryCount, courseCount, ...category }) => category) + const categories = Array.from(categoriesMap.values()) return { ...res, data: { @@ -165,61 +110,20 @@ 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 deleteCourseSubCategory = (subCategoryId: number) => - http.delete(`/course-management/sub-categories/${subCategoryId}`) - -export const getSubCategoriesByCategoryId = (categoryId: number) => - http.get(`/course-management/categories/${categoryId}/sub-categories`) - -export const createSubCategory = (payload: { - category_id: number - name: string - description?: string | null - display_order?: number -}) => http.post("/course-management/sub-categories", payload) - -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) => - withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => { + http.get("/course-management/hierarchy").then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] - - const requestedCategoryRows = rows.filter((r) => r.category_id === categoryId) - const requestedCategoryName = requestedCategoryRows.find((r) => !!r.category_name)?.category_name?.trim().toLowerCase() - const relevantRows = requestedCategoryName - ? rows.filter((r) => r.category_name?.trim().toLowerCase() === requestedCategoryName) - : requestedCategoryRows - - const courseMap = new Map() - relevantRows - .filter((r) => r.course_id) - .forEach((r) => { - const id = Number(r.course_id) - if (!Number.isFinite(id)) return - if (courseMap.has(id)) return - courseMap.set(id, { - id, - category_id: r.category_id, - sub_category_id: r.sub_category_id ?? null, - title: r.course_title ?? "", - description: "", - thumbnail: "", - is_active: true, - }) - }) - const courses = Array.from(courseMap.values()) - + const courses = rows + .filter((r) => r.category_id === categoryId && r.course_id) + .map((r) => ({ + id: Number(r.course_id), + category_id: r.category_id, + sub_category_id: r.sub_category_id ?? null, + title: r.course_title ?? "", + description: "", + thumbnail: "", + is_active: true, + })) return { ...res, data: { ...res.data, data: { courses, total_count: courses.length } }, @@ -243,39 +147,17 @@ export const updateCourseStatus = (courseId: number, isActive: boolean) => export const updateCourse = (courseId: number, data: UpdateCourseRequest) => http.put(`/course-management/courses/${courseId}`, data) -export const getCourseHierarchyByCourseId = (courseId: number) => - http.get(`/course-management/courses/${courseId}/hierarchy`) - // Sub-Module APIs (Unified Hierarchy) export const getSubModulesByCourse = (courseId: number) => - getCourseHierarchyByCourseId(courseId).then((res) => { - const raw = res.data?.data - const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : [] - const subModuleMap = new Map< - number, - { - id: number - course_id: number - level_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 - } - >() + http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => { + const rows: CourseHierarchyRow[] = res.data?.data ?? [] + const subModuleMap = new Map() rows.forEach((r, idx) => { if (!r.sub_module_id) return - const existing = subModuleMap.get(r.sub_module_id) - if (!existing) { + if (!subModuleMap.has(r.sub_module_id)) { subModuleMap.set(r.sub_module_id, { id: r.sub_module_id, course_id: courseId, - level_id: r.level_id ?? undefined, module_id: r.module_id ?? undefined, title: r.sub_module_title ?? "", description: "", @@ -286,17 +168,7 @@ export const getSubModulesByCourse = (courseId: number) => sub_level: r.cefr_level ?? undefined, is_active: true, }) - return } - subModuleMap.set(r.sub_module_id, { - ...existing, - module_id: existing.module_id ?? r.module_id ?? undefined, - level_id: existing.level_id ?? r.level_id ?? undefined, - title: existing.title || r.sub_module_title || "", - level: existing.level || r.cefr_level || "", - cefr_level: existing.cefr_level ?? r.cefr_level ?? undefined, - sub_level: existing.sub_level ?? r.cefr_level ?? undefined, - }) }) const sub_courses = Array.from(subModuleMap.values()) return { @@ -353,33 +225,6 @@ export const deleteSubModule = (subModuleId: number) => export const getVideosBySubModule = (subModuleId: number) => http.get(`/course-management/sub-modules/${subModuleId}/videos`) -export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) => - http.get(`/course-management/sub-modules/${subModuleId}/lessons`, { - params: { include_inactive: options?.includeInactive ?? true }, - }) - -export const getSubModuleLessonById = ( - lessonId: number, - options?: { - /** - * Cache-bust the request to avoid serving stale lesson data after edits. - * This is intentionally implemented via query string to work with default axios config. - */ - cacheBust?: boolean - }, -) => - http.get(`/course-management/sub-module-lessons/${lessonId}`, { - params: options?.cacheBust ? { _t: Date.now() } : undefined, - }) - -export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) => - http.put(`/course-management/sub-module-lessons/${lessonId}`, data) - -export const softDeleteSubModuleLesson = (lessonId: number) => - http.put(`/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, @@ -440,43 +285,6 @@ export const createPractice = (data: CreatePracticeRequest) => .then(() => res) }) -export const createLesson = (data: { - sub_module_id: number - title: string - description?: string - intro_video_url?: string - persona?: string - status?: "DRAFT" | "PUBLISHED" - passing_score?: number - time_limit_minutes?: number - shuffle_questions?: boolean -}) => - http - .post("/question-sets", { - title: data.title, - set_type: "QUIZ", - owner_type: "SUB_MODULE", - owner_id: data.sub_module_id, - ...(data.description?.trim() ? { description: data.description.trim() } : {}), - ...(data.intro_video_url?.trim() ? { intro_video_url: data.intro_video_url.trim() } : {}), - ...(data.persona?.trim() ? { persona: data.persona.trim() } : {}), - ...(data.status ? { status: data.status } : {}), - ...(Number.isFinite(data.passing_score) ? { passing_score: data.passing_score } : {}), - ...(Number.isFinite(data.time_limit_minutes) ? { time_limit_minutes: data.time_limit_minutes } : {}), - ...(typeof data.shuffle_questions === "boolean" ? { shuffle_questions: data.shuffle_questions } : {}), - }) - .then((res) => { - const questionSetID = res.data?.data?.id - if (!questionSetID) return res - return http - .post("/course-management/sub-module-lessons", { - sub_module_id: data.sub_module_id, - question_set_id: questionSetID, - intro_video_url: data.intro_video_url, - }) - .then(() => res) - }) - export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) => http.put(`/course-management/practices/${practiceId}`, data) @@ -698,92 +506,186 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st params: { cefr_level }, }) -export const getHumanLanguageSubCategories = () => - http.get("/course-management/human-language/sub-categories") +export const getHumanLanguageHierarchy = () => + http.get("/course-management/hierarchy").then(async (res) => { + const payload = res.data?.data as unknown + if (payload && typeof payload === "object" && !Array.isArray(payload) && "sub_categories" in payload) { + return res + } -export const getCoursesBySubCategoryId = (subCategoryId: number) => - http.get(`/course-management/sub-categories/${subCategoryId}/courses`) + const rows: UnifiedHierarchyRow[] = Array.isArray(payload) ? payload : [] + const categoryMap = new Map< + number, + { + category_id: number + category_name: string + sub_categories: Map< + number, + { + sub_category_id: number + sub_category_name: string + courses: Map< + number, + { + course_id: number + course_name: string + } + > + } + > + } + >() -export const getSubModulesByModuleId = (moduleId: number) => - http.get(`/course-management/modules/${moduleId}/sub-modules`) + rows.forEach((row) => { + const categoryId = Number(row.category_id) + if (!Number.isFinite(categoryId)) return -/** - * Finds a sub-module under a course by walking levels → modules → sub-modules APIs. - * Use when the legacy hierarchy flatten (`getSubModulesByCourse`) does not include the row. - */ -export async function resolveSubModuleForCourse( - courseId: number, - subModuleId: number, -): Promise { - 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 ?? "")) + if (!categoryMap.has(categoryId)) { + categoryMap.set(categoryId, { + category_id: categoryId, + category_name: row.category_name ?? "", + sub_categories: new Map(), + }) + } + + if (!row.sub_category_id) return + const subCategoryId = Number(row.sub_category_id) + if (!Number.isFinite(subCategoryId)) return + + const categoryNode = categoryMap.get(categoryId)! + if (!categoryNode.sub_categories.has(subCategoryId)) { + categoryNode.sub_categories.set(subCategoryId, { + sub_category_id: subCategoryId, + sub_category_name: row.sub_category_name ?? "", + courses: new Map(), + }) + } + + if (!row.course_id) return + const courseId = Number(row.course_id) + if (!Number.isFinite(courseId)) return + + const subCategoryNode = categoryNode.sub_categories.get(subCategoryId)! + if (!subCategoryNode.courses.has(courseId)) { + subCategoryNode.courses.set(courseId, { + course_id: courseId, + course_name: row.course_title ?? "", + }) + } }) - 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 selectedCategory = + Array.from(categoryMap.values()).find((c) => c.category_name.toLowerCase().includes("human")) ?? + Array.from(categoryMap.values())[0] - 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, - } - } + if (!selectedCategory) { + return { + ...res, + data: { + ...res.data, + data: { + category_id: 0, + category_name: "", + sub_categories: [], + }, + }, + } as unknown as { data: GetHumanLanguageHierarchyResponse } } - } catch (e) { - console.error("resolveSubModuleForCourse failed:", e) - } - return null -} -export const getCourseLevelsForCourse = (courseId: number) => - http.get(`/course-management/courses/${courseId}/levels`) + const courses = Array.from(selectedCategory.sub_categories.values()).flatMap((sub) => + Array.from(sub.courses.values()).map((course) => ({ sub_category_id: sub.sub_category_id, course })), + ) -export const getAllCourseLevels = () => http.get("/course-management/levels") + const hierarchyResponses = await Promise.all( + courses.map(({ course }) => + http + .get(`/course-management/courses/${course.course_id}/hierarchy`) + .then((courseRes) => ({ course_id: course.course_id, rows: (courseRes.data?.data ?? []) as CourseHierarchyRow[] })) + .catch(() => ({ course_id: course.course_id, rows: [] as CourseHierarchyRow[] })), + ), + ) -export const getCourseLevelById = (levelId: number) => - http.get(`/course-management/levels/${levelId}`) + const hierarchyByCourse = new Map( + hierarchyResponses.map((h) => [h.course_id, h.rows]), + ) -export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) => - withSingleRetry(() => - http.get("/course-management/human-language/hierarchy", { - params: options?.cacheBust ? { _t: Date.now() } : undefined, - }), - ) + const subCategories = Array.from(selectedCategory.sub_categories.values()).map((sub) => ({ + sub_category_id: sub.sub_category_id, + sub_category_name: sub.sub_category_name, + courses: Array.from(sub.courses.values()).map((course) => { + const levelMap = new Map< + string, + { + level: string + modules: Map< + number, + { + id: number + title: string + sub_modules: Map + } + > + } + >() + + ;(hierarchyByCourse.get(course.course_id) ?? []).forEach((row) => { + if (!row.level_id || !row.cefr_level) return + const levelKey = String(row.cefr_level).toUpperCase() + if (!levelMap.has(levelKey)) { + levelMap.set(levelKey, { level: levelKey, modules: new Map() }) + } + + if (!row.module_id) return + const levelNode = levelMap.get(levelKey)! + const moduleId = Number(row.module_id) + if (!levelNode.modules.has(moduleId)) { + levelNode.modules.set(moduleId, { + id: moduleId, + title: row.module_title ?? "", + sub_modules: new Map(), + }) + } + + if (!row.sub_module_id) return + const moduleNode = levelNode.modules.get(moduleId)! + const subModuleId = Number(row.sub_module_id) + if (!moduleNode.sub_modules.has(subModuleId)) { + moduleNode.sub_modules.set(subModuleId, { + id: subModuleId, + title: row.sub_module_title ?? "", + videos: [], + practices: [], + }) + } + }) + + return { + course_id: course.course_id, + course_name: course.course_name, + levels: Array.from(levelMap.values()).map((levelNode) => ({ + level: levelNode.level, + modules: Array.from(levelNode.modules.values()).map((moduleNode) => ({ + id: moduleNode.id, + title: moduleNode.title, + sub_modules: Array.from(moduleNode.sub_modules.values()), + })), + })), + } + }), + })) + + return { + ...res, + data: { + ...res.data, + data: { + category_id: selectedCategory.category_id, + category_name: selectedCategory.category_name, + sub_categories: subCategories, + }, + }, + } as unknown as { data: GetHumanLanguageHierarchyResponse } + }) export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) => http @@ -812,34 +714,6 @@ export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest }), ) -export const createModuleInLevel = ( - levelId: number, - title: string, - description: string, - displayOrder = 0, -) => - http.post("/course-management/modules", { - level_id: levelId, - title, - description, - display_order: displayOrder, - is_active: true, - }) - -export const createSubModuleInModule = ( - moduleId: number, - title: string, - description: string, - displayOrder = 0, -) => - http.post("/course-management/sub-modules", { - module_id: moduleId, - title, - description, - display_order: displayOrder, - is_active: true, - }) - export const getSubModuleEntryAssessment = (subModuleId: number) => http.get( `/question-sets/sub-courses/${subModuleId}/entry-assessment`, diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index d14f85d..b93e3ba 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -1,53 +1,66 @@ -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 { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage" -import { SubModulesPage } from "../pages/content-management/SubCoursesPage" -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 { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage" -import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage" -import { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage" -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 { 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 { 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 { 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"; export function AppRoutes() { return ( @@ -79,46 +92,65 @@ export function AppRoutes() { } /> } /> } /> - } /> + } /> } /> - } - /> } /> - } - /> } /> - } /> } + path="category/:categoryId" + element={} + /> + } /> - } /> {/* Course → Sub-module → Lesson/Practice */} - } /> - } /> - } /> - } /> - } /> + } + /> + } + /> + } + /> + } + /> {/* Legacy aliases */} - } /> - } /> - } /> - } /> - } /> - } /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> } /> } /> @@ -128,8 +160,65 @@ export function AppRoutes() { } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> - } /> + } + /> } /> } /> } /> @@ -143,7 +232,5 @@ export function AppRoutes() { } /> - ) + ); } - - diff --git a/src/assets/icons/upload.png b/src/assets/icons/upload.png new file mode 100644 index 0000000..44342e3 Binary files /dev/null and b/src/assets/icons/upload.png differ diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 10bd6da..ce361c2 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -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", )} > -
+
{isCollapsed ? ( @@ -103,7 +116,11 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side onClick={onToggleCollapse} aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"} > - {isCollapsed ? : } + {isCollapsed ? ( + + ) : ( + + )}
- ) + ); } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 5095b8b..37ed927 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -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, @@ -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, @@ -38,27 +38,42 @@ const DialogContent = React.forwardRef< {...props} > {children} - - + + Close -)) -DialogContent.displayName = DialogPrimitive.Content.displayName +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => (
-) -DialogFooter.displayName = "DialogFooter" +); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, @@ -66,11 +81,14 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, @@ -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, -} - +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index c46b5cd..ba422f1 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -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 {} -export const Input = React.forwardRef(({ className, type, ...props }, ref) => { - return ( - - ) -}) -Input.displayName = "Input" - - +export const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 278e514..7af6a45 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -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 {} @@ -18,10 +18,9 @@ export const Select = React.forwardRef( > {children} - +
- ) + ); }, -) -Select.displayName = "Select" - +); +Select.displayName = "Select"; diff --git a/src/components/ui/stepper.tsx b/src/components/ui/stepper.tsx index a4627aa..0fe69de 100644 --- a/src/components/ui/stepper.tsx +++ b/src/components/ui/stepper.tsx @@ -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 ( -
+
{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 ( - -
-
-
- {isCompleted ? : stepNumber} -
- - {step} - -
-
+
+ {/* Connector Line - floats between circles with gap on both sides */} {index < steps.length - 1 && (
)} - - ) + + {/* Circle */} +
+ {stepNumber} +
+ + {/* Label */} + + {step} + +
+ ); })}
- ) + ); } - diff --git a/src/pages/content-management/AddNewPracticePage.tsx b/src/pages/content-management/AddNewPracticePage.tsx index 1c8a7f2..ad370db 100644 --- a/src/pages/content-management/AddNewPracticePage.tsx +++ b/src/pages/content-management/AddNewPracticePage.tsx @@ -1,100 +1,156 @@ -import { useMemo, useRef, useState, type ChangeEvent } from "react" -import { Link, useLocation, useParams, useNavigate } from "react-router-dom" -import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, Edit, Rocket, Upload } from "lucide-react" -import { toast } from "sonner" -import { Card } from "../../components/ui/card" -import { Button } from "../../components/ui/button" -import { Input } from "../../components/ui/input" -import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields" -import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api" -import { uploadVideoFile } from "../../api/files.api" -import { Select } from "../../components/ui/select" -import { SpinnerIcon } from "../../components/ui/spinner-icon" -import type { QuestionOption } from "../../types/course.types" +import { useMemo, useRef, useState, type ChangeEvent } from "react"; +import { Link, useLocation, useParams, useNavigate } from "react-router-dom"; +import { + ArrowLeft, + ArrowRight, + ChevronDown, + Grid3X3, + Check, + Plus, + Trash2, + GripVertical, + Edit, + Rocket, + Loader2, + Upload, +} from "lucide-react"; +import { toast } from "sonner"; +import { Card } from "../../components/ui/card"; +import { Button } from "../../components/ui/button"; +import { Input } from "../../components/ui/input"; +import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"; +import { + createQuestionSet, + createQuestion, + addQuestionToSet, +} from "../../api/courses.api"; +import { uploadVideoFile } from "../../api/files.api"; +import { Select } from "../../components/ui/select"; +import type { QuestionOption } from "../../types/course.types"; -type Step = 1 | 2 | 3 | 4 | 5 -type ResultStatus = "success" | "error" -type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" -type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" +type Step = 1 | 2 | 3 | 4 | 5; +type ResultStatus = "success" | "error"; +type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"; +type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"; interface Persona { - id: string - name: string - avatar: string + id: string; + name: string; + avatar: string; } interface MCQOption { - text: string - isCorrect: boolean + 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 + 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 PERSONAS: Persona[] = [ - { id: "1", name: "Dawit", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit" }, - { id: "2", name: "Mahlet", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet" }, - { id: "3", name: "Amanuel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel" }, - { id: "4", name: "Bethel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel" }, - { id: "5", name: "Liya", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya" }, - { id: "6", name: "Aseffa", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa" }, - { id: "7", name: "Hana", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana" }, - { id: "8", name: "Nahom", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom" }, -] + { + id: "1", + name: "Dawit", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit", + }, + { + id: "2", + name: "Mahlet", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet", + }, + { + id: "3", + name: "Amanuel", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel", + }, + { + id: "4", + name: "Bethel", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel", + }, + { + id: "5", + name: "Liya", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya", + }, + { + id: "6", + name: "Aseffa", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa", + }, + { + id: "7", + name: "Hana", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana", + }, + { + id: "8", + name: "Nahom", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom", + }, +]; const STEPS = [ { number: 1, label: "Context" }, { number: 2, label: "Persona" }, { number: 3, label: "Questions" }, { number: 4, label: "Review" }, -] +]; /** Prefer direct storage URL; for Vimeo pipeline match SubCourseContentPage player URL shape. */ -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() +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 + const hashFromUrl = pageUrl + ? pageUrl.split("/").filter(Boolean).at(-1) + : undefined; + return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl; } - return pageUrl || null + 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") + 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}` + : `https://player.vimeo.com/video/${videoId}`; } catch { - return null + return null; } } function isDirectVideoFile(url: string): boolean { - const clean = url.split("?")[0].toLowerCase() - return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean) + const clean = url.split("?")[0].toLowerCase(); + return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean); } function escapeHtml(raw: string): string { @@ -103,45 +159,56 @@ function escapeHtml(raw: string): string { .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) - .replaceAll("'", "'") + .replaceAll("'", "'"); } function sanitizeAdminRichTextHtml(input: string): string { - if (!input.trim()) return "" + if (!input.trim()) return ""; try { - const parser = new DOMParser() - const doc = parser.parseFromString(input, "text/html") - const blockedTags = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"]) + const parser = new DOMParser(); + const doc = parser.parseFromString(input, "text/html"); + const blockedTags = new Set([ + "script", + "style", + "iframe", + "object", + "embed", + "link", + "meta", + ]); doc.body.querySelectorAll("*").forEach((el) => { - const tagName = el.tagName.toLowerCase() + const tagName = el.tagName.toLowerCase(); if (blockedTags.has(tagName)) { - el.remove() - return + el.remove(); + return; } - const attrs = [...el.attributes] + const attrs = [...el.attributes]; attrs.forEach((attr) => { - const name = attr.name.toLowerCase() - const value = attr.value.trim().toLowerCase() + const name = attr.name.toLowerCase(); + const value = attr.value.trim().toLowerCase(); if (name.startsWith("on")) { - el.removeAttribute(attr.name) - return + el.removeAttribute(attr.name); + return; } - if ((name === "href" || name === "src") && value.startsWith("javascript:")) { - el.removeAttribute(attr.name) + if ( + (name === "href" || name === "src") && + value.startsWith("javascript:") + ) { + el.removeAttribute(attr.name); } - }) - }) - return doc.body.innerHTML + }); + }); + return doc.body.innerHTML; } catch { - return escapeHtml(input).replace(/\r?\n/g, "
") + return escapeHtml(input).replace(/\r?\n/g, "
"); } } function formatDescriptionForPreview(raw: string): string { - if (!raw.trim()) return "" - const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw) - if (hasHtml) return sanitizeAdminRichTextHtml(raw) - return escapeHtml(raw).replace(/\r?\n/g, "
") + if (!raw.trim()) return ""; + const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw); + if (hasHtml) return sanitizeAdminRichTextHtml(raw); + return escapeHtml(raw).replace(/\r?\n/g, "
"); } function createEmptyQuestion(id: string): Question { @@ -164,178 +231,197 @@ function createEmptyQuestion(id: string): Question { audioCorrectAnswerText: "", shortAnswers: [], imageUrl: "", - } + }; } export function AddNewPracticePage() { - const { categoryId, courseId, subModuleId } = useParams() - const location = useLocation() - const navigate = useNavigate() - const searchParams = new URLSearchParams(location.search) - const source = searchParams.get("source") + const { categoryId, courseId, subModuleId } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const searchParams = new URLSearchParams(location.search); + const source = searchParams.get("source"); const backTo = useMemo(() => { - if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) { - return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}` + if ( + location.pathname.includes("/content/human-language/") && + location.pathname.includes("/sub-module/") + ) { + return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`; } - if (source === "human-language") return "/content/human-language" - return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` - }, [location.pathname, source, categoryId, courseId, subModuleId]) - - const [currentStep, setCurrentStep] = useState(1) - const [saving, setSaving] = useState(false) + if (source === "human-language") return "/content/human-language"; + return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`; + }, [location.pathname, source, categoryId, courseId, subModuleId]); + + const [currentStep, setCurrentStep] = useState(1); + const [saving, setSaving] = useState(false); // Step 1: Context - const [selectedProgram] = useState("Intermediate") - const [selectedCourse] = useState("B2") - const [practiceTitle, setPracticeTitle] = useState("") - const [practiceDescription, setPracticeDescription] = useState("") - const [introVideoUrl, setIntroVideoUrl] = useState("") - const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false) - const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false) - const introVideoFileInputRef = useRef(null) - const [shuffleQuestions, setShuffleQuestions] = useState(false) - const [passingScore, setPassingScore] = useState(50) - const [timeLimitMinutes, setTimeLimitMinutes] = useState(60) - const [saveError, setSaveError] = useState(null) - const [resultStatus, setResultStatus] = useState(null) - const [resultMessage, setResultMessage] = useState("") + const [selectedProgram] = useState("Intermediate"); + const [selectedCourse] = useState("B2"); + const [practiceTitle, setPracticeTitle] = useState(""); + const [practiceDescription, setPracticeDescription] = useState(""); + const [introVideoUrl, setIntroVideoUrl] = useState(""); + const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false); + const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false); + const introVideoFileInputRef = useRef(null); + const [shuffleQuestions, setShuffleQuestions] = useState(false); + const [passingScore, setPassingScore] = useState(50); + const [timeLimitMinutes, setTimeLimitMinutes] = useState(60); + const [saveError, setSaveError] = useState(null); + const [resultStatus, setResultStatus] = useState(null); + const [resultMessage, setResultMessage] = useState(""); // Step 2: Persona - const [selectedPersona, setSelectedPersona] = useState(null) + const [selectedPersona, setSelectedPersona] = useState(null); // Step 3: Questions const [questions, setQuestions] = useState([ createEmptyQuestion("1"), - ]) + ]); const handleNext = () => { if (currentStep < 4) { - setCurrentStep((currentStep + 1) as Step) + setCurrentStep((currentStep + 1) as Step); } - } + }; const handleBack = () => { if (currentStep > 1) { - setCurrentStep((currentStep - 1) as Step) + setCurrentStep((currentStep - 1) as Step); } - } + }; const handleCancel = () => { - navigate(backTo) - } + navigate(backTo); + }; - const handleIntroVideoFileChange = async (event: ChangeEvent) => { - const file = event.target.files?.[0] - event.target.value = "" - if (!file) return + const handleIntroVideoFileChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; - setUploadingIntroVideo(true) + setUploadingIntroVideo(true); try { const uploadRes = await uploadVideoFile(file, { - title: practiceTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Practice intro", + title: + practiceTitle.trim() || + file.name.replace(/\.[^.]+$/, "") || + "Practice intro", description: practiceDescription.trim() || undefined, - }) - const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data) - if (!finalUrl) throw new Error("Missing uploaded video url") - setIntroVideoUrl(finalUrl) - toast.success("Intro video uploaded", { description: "The URL has been filled in for you." }) + }); + const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data); + if (!finalUrl) throw new Error("Missing uploaded video url"); + setIntroVideoUrl(finalUrl); + toast.success("Intro video uploaded", { + description: "The URL has been filled in for you.", + }); } catch (error) { - console.error("Failed to upload intro video:", error) - toast.error("Failed to upload intro video") + console.error("Failed to upload intro video:", error); + toast.error("Failed to upload intro video"); } finally { - setUploadingIntroVideo(false) + setUploadingIntroVideo(false); } - } + }; const handleImportIntroVideoFromUrl = async () => { - const source = introVideoUrl.trim() - if (!source || !/^https?:\/\//i.test(source)) return - const vimeoEmbed = toVimeoEmbedUrl(source) + const source = introVideoUrl.trim(); + if (!source || !/^https?:\/\//i.test(source)) return; + const vimeoEmbed = toVimeoEmbedUrl(source); // Vimeo page URLs can be protected by anti-bot checks when server-side fetched. // For those links, prefer local normalization to player URL instead of failing import. if (vimeoEmbed) { - setIntroVideoUrl(vimeoEmbed) - return + setIntroVideoUrl(vimeoEmbed); + return; } - setImportingIntroVideoUrl(true) + setImportingIntroVideoUrl(true); try { const uploadRes = await uploadVideoFile(source, { title: practiceTitle.trim() || "Practice intro", description: practiceDescription.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", { description: "Processed via /files/upload." }) + }); + const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data); + if (!finalUrl) throw new Error("Missing uploaded video url"); + setIntroVideoUrl(finalUrl); + toast.success("Intro video URL imported", { + description: "Processed via /files/upload.", + }); } catch (error) { - console.error("Failed to import intro video URL:", error) - toast.error("Failed to import intro video URL") + console.error("Failed to import intro video URL:", error); + toast.error("Failed to import intro video URL"); } finally { - setImportingIntroVideoUrl(false) + setImportingIntroVideoUrl(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 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 descriptionPreviewHtml = useMemo( () => formatDescriptionForPreview(practiceDescription), [practiceDescription], - ) + ); const addQuestion = () => { - setQuestions([...questions, createEmptyQuestion(String(Date.now()))]) - } + setQuestions([...questions, createEmptyQuestion(String(Date.now()))]); + }; const removeQuestion = (id: string) => { if (questions.length > 1) { - setQuestions(questions.filter(q => q.id !== id)) + setQuestions(questions.filter((q) => q.id !== id)); } - } + }; const updateQuestion = (id: string, updates: Partial) => { - setQuestions(questions.map(q => q.id === id ? { ...q, ...updates } : q)) - } + setQuestions( + questions.map((q) => (q.id === id ? { ...q, ...updates } : q)), + ); + }; const saveQuestionSet = async (status: "DRAFT" | "PUBLISHED") => { - setSaving(true) - setSaveError(null) + setSaving(true); + setSaveError(null); try { - const persona = PERSONAS.find(p => p.id === selectedPersona) + const persona = PERSONAS.find((p) => p.id === selectedPersona); const setRes = await createQuestionSet({ title: practiceTitle || "Untitled Practice", set_type: "PRACTICE", owner_type: "SUB_MODULE", owner_id: Number(subModuleId), - ...(practiceDescription.trim() ? { description: practiceDescription.trim() } : {}), + ...(practiceDescription.trim() + ? { description: practiceDescription.trim() } + : {}), ...(persona?.name ? { persona: persona.name } : {}), shuffle_questions: shuffleQuestions, status, passing_score: passingScore, time_limit_minutes: timeLimitMinutes, - ...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}), - }) + ...(introVideoUrl.trim() + ? { intro_video_url: introVideoUrl.trim() } + : {}), + }); - const questionSetId = setRes.data?.data?.id + const questionSetId = setRes.data?.data?.id; if (questionSetId) { for (let i = 0; i < questions.length; i++) { - const q = questions[i] - if (!q.questionText.trim()) continue + 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 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, @@ -350,734 +436,926 @@ export function AddNewPracticePage() { 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, - }) + short_answers: + q.shortAnswers.length > 0 ? q.shortAnswers : undefined, + }); - const questionId = qRes.data?.data?.id + const questionId = qRes.data?.data?.id; if (questionId) { await addQuestionToSet(questionSetId, { display_order: i + 1, question_id: questionId, - }) + }); } } } - setResultStatus("success") + setResultStatus("success"); setResultMessage( status === "PUBLISHED" ? "Your speaking practice is now active." - : "Your practice has been saved as a draft." - ) - setCurrentStep(5) + : "Your practice has been saved as a draft.", + ); + setCurrentStep(5); } catch (err: unknown) { - console.error("Failed to save practice:", err) - const errorMsg = err instanceof Error ? err.message : "An unexpected error occurred." - setResultStatus("error") - setResultMessage(errorMsg) - setCurrentStep(5) + console.error("Failed to save practice:", err); + const errorMsg = + err instanceof Error ? err.message : "An unexpected error occurred."; + setResultStatus("error"); + setResultMessage(errorMsg); + setCurrentStep(5); } finally { - setSaving(false) + setSaving(false); } - } + }; - const handleSaveAsDraft = () => saveQuestionSet("DRAFT") - const handlePublish = () => saveQuestionSet("PUBLISHED") + const handleSaveAsDraft = () => saveQuestionSet("DRAFT"); + const handlePublish = () => saveQuestionSet("PUBLISHED"); const getNextButtonLabel = () => { switch (currentStep) { - case 1: return "Next: Persona" - case 2: return "Next: Questions" - case 3: return "Next: Review" - default: return "Next" + case 1: + return "Next: Persona"; + case 2: + return "Next: Questions"; + case 3: + return "Next: Review"; + default: + return "Next"; } - } + }; return (
- {currentStep !== 5 && ( - <> - {/* Back Link */} - - - Back to Sub-course - - - {/* Header */} -
-

Add New Practice

-

- Create a new immersive practice session for students. -

-
- - )} - - {/* Step Tracker */} - {currentStep !== 5 && ( -
- {STEPS.map((step, index) => ( -
-
-
step.number - ? "bg-brand-500 text-white" - : "border-2 border-grayScale-300 bg-white text-grayScale-400" - }`} - > - {currentStep > step.number ? : step.number} -
- step.number - ? "text-brand-500" - : "text-grayScale-400" - }`} - > - {step.label} - -
- {index < STEPS.length - 1 && ( -
step.number ? "bg-brand-500" : "bg-grayScale-200" - }`} - /> - )} -
- ))} -
- )} - - {/* Step Content */} - {currentStep === 1 && ( - -
-

Step 1: Context

-

- Define details and rules for this practice. Curriculum context is shown on the right. -

-
- -
-
-
-
- - setPracticeTitle(e.target.value)} - placeholder="Enter practice title" - className="h-11" - /> -
- -
- -