diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index c93de0f..5ed820d 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -44,18 +44,40 @@ 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, } from "../types/course.types" type UnifiedHierarchyRow = { @@ -110,6 +132,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(`/course-management/categories/${categoryId}/sub-categories`) + +export const getCoursesBySubCategoryId = (subCategoryId: number) => + http.get(`/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 +199,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(`/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() rows.forEach((r, idx) => { if (!r.sub_module_id) return @@ -225,6 +280,27 @@ 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?: { 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, @@ -345,6 +421,63 @@ 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("/programs", { params }) + +export const createLearningProgram = (data: CreateLearningProgramRequest) => + http.post("/programs", data) + +export const getProgramCourses = ( + programId: number, + params?: { limit?: number; offset?: number }, +) => http.get(`/programs/${programId}/courses`, { params }) + +export const createProgramCourse = ( + programId: number, + data: CreateProgramCourseRequest, +) => http.post(`/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(`/courses/${courseId}/modules`, { + params, + }) + +/** Learn English top-level module — POST /courses/:courseId/modules */ +export const createTopLevelCourseModule = ( + courseId: number, + data: CreateTopLevelCourseModuleRequest, +) => + http.post( + `/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}`) + +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 +516,74 @@ export const deleteLevel = (levelId: number) => export const getModulesByLevel = (levelId: number) => http.get(`/course-management/levels/${levelId}/modules`) +export const getCourseLevelsForCourse = (courseId: number) => + http.get(`/course-management/courses/${courseId}/levels`) + +export const getSubModulesByModuleId = (moduleId: number) => + http.get(`/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 { + 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) diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index b93e3ba..5e65cce 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -45,7 +45,7 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"; import { QuestionsPage } from "../pages/content-management/QuestionsPage"; import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"; -import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"; +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"; @@ -92,7 +92,7 @@ export function AppRoutes() { } /> } /> } /> - } /> + } /> } diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index d3425ab..c3c1f1a 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -67,9 +67,6 @@ export function LoginPage() { const navigate = useNavigate(); const token = localStorage.getItem("access_token"); - if (token) { - return ; - } const [showPassword, setShowPassword] = useState(false); const [email, setEmail] = useState(""); @@ -162,6 +159,10 @@ export function LoginPage() { } }, [googleReady, handleGoogleCallback]); + if (token) { + return ; + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/pages/content-management/AllCoursesPage.tsx b/src/pages/content-management/AllCoursesPage.tsx index 10c44de..b7c5ba4 100644 --- a/src/pages/content-management/AllCoursesPage.tsx +++ b/src/pages/content-management/AllCoursesPage.tsx @@ -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 (
-
- -
-

Loading all sub-categories…

+
) } diff --git a/src/pages/content-management/CourseDetailPage.tsx b/src/pages/content-management/CourseDetailPage.tsx index 1493eae..67725c0 100644 --- a/src/pages/content-management/CourseDetailPage.tsx +++ b/src/pages/content-management/CourseDetailPage.tsx @@ -1,178 +1,593 @@ -import { useState } from "react"; -import { ArrowLeft, Plus, Calendar, Plane, Clock, Hand } from "lucide-react"; +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"; - -const MODULES = [ - { - id: "m1", - title: "Introduction Basics", - description: "Learn basic English words, phrases, and simple sentences.", - icon: Hand, - status: "Published", - gradient: "from-[#8E44AD] to-[#C39BD3]", - }, - { - id: "m2", - title: "Daily Routines", - description: "Vocabulary related to waking up, and evening activities.", - icon: Clock, - status: "Draft", - gradient: "from-[#8E44AD] to-[#C39BD3]", - }, - { - id: "m3", - title: "Travel Essentials", - description: - "Key phrases for airports, hotels, and asking for help while abroad.", - icon: Plane, - status: "Draft", - gradient: "from-[#8E44AD] to-[#C39BD3]", - }, -]; - +import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"; +import alertSrc from "../../assets/Alert.svg"; +import { + deleteTopLevelCourseModule, + getProgramCourses, + getTopLevelCourseModules, + updateTopLevelCourseModule, +} from "../../api/courses.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:") + ); +} + +/** 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 ( +
+
+ {tryCover ? ( + setCoverFailed(true)} + /> + ) : null} +
+ ); +} + +/** 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 ( +
+ {showImg ? ( + setImgFailed(true)} + /> + ) : ( + + )} +
+ ); +} export function CourseDetailPage() { const navigate = useNavigate(); - const { level, courseId } = useParams<{ level: string; courseId: string }>(); + const { level: programIdParam, courseId: courseIdParam } = useParams<{ + level: string; + courseId: string; + }>(); + const programId = Number(programIdParam); + const courseIdNum = Number(courseIdParam); + + const [course, setCourse] = useState(null); + const [modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [isAddModuleOpen, setIsAddModuleOpen] = useState(false); + const [editingModule, setEditingModule] = + useState(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(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 sorted = [...list].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 (
{/* Header Navigation */}
- Back to Levels + Back to Courses
- {/* Hero Section */} -
-
-

- {courseId?.toUpperCase() || "A1"} -

-

- Learn basic English words, phrases, and simple sentences for daily - situations. -

+ {loading ? ( +
+
-
+ ) : error && !course ? ( +
+ +

{error}

-
-
-
-