program+course+module integrations

This commit is contained in:
Yared Yemane 2026-04-24 08:27:39 -07:00
parent c4ebbd903d
commit 3634d2eb79
11 changed files with 2600 additions and 571 deletions

View File

@ -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<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 +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<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 +280,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 +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<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}`)
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<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

@ -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() {
<Route index element={<CourseCategoryPage />} />
<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 />}

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

@ -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

@ -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 (
<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, courseId } = useParams<{ level: string; courseId: string }>();
const { level: programIdParam, courseId: courseIdParam } = useParams<{
level: string;
courseId: string;
}>();
const programId = Number(programIdParam);
const courseIdNum = Number(courseIdParam);
const [course, setCourse] = useState<ProgramCourseListItem | null>(null);
const [modules, setModules] = useState<TopLevelCourseModuleItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAddModuleOpen, setIsAddModuleOpen] = useState(false);
const [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 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 (
<div className="space-y-10 pb-20 pt-10">
{/* Header Navigation */}
<div className="flex items-center gap-2">
<Link
to={`/new-content/learn-english/${level}/courses`}
to={`/new-content/learn-english/${programIdParam}/courses`}
className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500"
>
<ArrowLeft className="h-5 w-5" />
Back to Levels
Back to Courses
</Link>
</div>
{/* Hero Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="">
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
{courseId?.toUpperCase() || "A1"}
</h1>
<p className="text-grayScale-500 text-sm max-w-2xl font-medium">
Learn basic English words, phrases, and simple sentences for daily
situations.
</p>
{loading ? (
<div className="flex flex-col items-center justify-center py-16">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
</div>
<div className="flex items-center gap-4">
) : 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="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=modules&courseId=${courseId}`,
)
}
className="mt-4"
onClick={() => void loadPage()}
>
<Calendar className="h-4 w-4" />
Add Practice
</Button>
<Button
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
onClick={() => setIsAddModuleOpen(true)}
>
<Plus className="h-4 w-4" />
Add Module
Try again
</Button>
</div>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</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 */}
) : (
<>
{/* Hero Section */}
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
{displayTitle}
</h1>
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
{displayDescription}
</p>
</div>
<div className="flex items-center gap-4">
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
<Calendar className="h-4 w-4" />
Add Practice
</Button>
<Button
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
onClick={() => setIsAddModuleOpen(true)}
>
<Plus className="h-4 w-4" />
Add Module
</Button>
</div>
</div>
<div className="relative">
<div
className={cn(
"h-36 w-full bg-gradient-to-b opacity-90 transition-transform duration-700",
module.gradient,
)}
/>
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full rounded-full opacity-20"
style={{
background: "gray",
}}
/>
</div>
</div>
<div className="p-2 pb-4 pt-4 flex-1 flex flex-col">
<div className="flex gap-4 mb-8">
{/* Icon Circle */}
<div
className={`h-12 w-12 rounded-full ${module.id === "m2" ? "bg-[#F8FAFC]" : "bg-[#f3e8ff]"} flex items-center justify-center p-3 flex-shrink-0 border border-purple-100/50`}
>
<module.icon
className={`h-6 w-6 ${module.id === "m2" ? "text-[#64748B]" : "text-brand-500"}`}
<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>
{/* Content */}
<div className="space-y-1">
<h3 className="text-lg font-bold text-[#0F172A] tracking-tight">
{module.title}
</h3>
<p className="text-grayScale-400 font-medium text-[12px]">
{module.description}
{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}`,
)
}
>
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>
{/* Actions */}
<div className="flex items-center gap-3 mt-auto">
<Button
variant="outline"
className="flex-1 h-10 rounded-[6px] border-[#9E2891] text-[#9E2891] transition-all text-sm"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${module.id}`,
)
}
>
View Detail
</Button>
{module.status === "Published" ? (
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button
disabled
className="flex-1 h-10 rounded-[6px] bg-[#D291BC] text-white opacity-100 cursor-default border-none shadow-none text-sm"
type="button"
variant="outline"
onClick={() => setDeletingModule(null)}
disabled={deletingModuleInFlight}
className="w-full sm:w-auto"
>
Published
Cancel
</Button>
) : (
<Button className="flex-1 h-10 rounded-[6px] bg-brand-500 text-white shadow-md shadow-brand-500/10 text-sm">
Publish Practice
<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>
</Card>
))}
</div>
)}
</>
)}
</div>
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -59,7 +59,6 @@ export function SubCategoryCoursesPage() {
return (
<div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<p className="mt-4 text-sm text-grayScale-500">Loading courses</p>
</div>
)
}

View File

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

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

@ -57,6 +57,148 @@ 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
/** When the API adds aggregates, map these for the course cards. */
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
}
// ============================================
// Legacy Types (deprecated - using SubCourse hierarchy now)
// Keeping for backward compatibility with existing API endpoints