program+course+module integrations
This commit is contained in:
parent
c4ebbd903d
commit
3634d2eb79
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +1,328 @@
|
|||
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>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
) : error && !course ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => void loadPage()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<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"}
|
||||
<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="text-grayScale-500 text-sm max-w-2xl font-medium">
|
||||
Learn basic English words, phrases, and simple sentences for daily
|
||||
situations.
|
||||
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
|
||||
{displayDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -70,7 +331,7 @@ export function CourseDetailPage() {
|
|||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/add-practice?backTo=modules&courseId=${courseId}`,
|
||||
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
@ -87,12 +348,15 @@ export function CourseDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<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"
|
||||
className="h-[0.5px] w-full rounded-full opacity-20"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
|
|
@ -103,76 +367,227 @@ export function CourseDetailPage() {
|
|||
<AddModuleModal
|
||||
isOpen={isAddModuleOpen}
|
||||
onClose={() => setIsAddModuleOpen(false)}
|
||||
/>
|
||||
{/* Gradient Divider */}
|
||||
|
||||
{/* Gradient Grid */}
|
||||
<div className="flex flex-warp gap-10">
|
||||
{MODULES.map((module) => (
|
||||
<Card
|
||||
key={module.id}
|
||||
className="group overflow-hidden border w-[330px] border-grayScale-50 shadow-sm hover:shadow-lg transition-all duration-300 rounded-[16px] bg-white flex flex-col h-full"
|
||||
>
|
||||
{/* Gradient Banner */}
|
||||
<div
|
||||
className={cn(
|
||||
"h-36 w-full bg-gradient-to-b opacity-90 transition-transform duration-700",
|
||||
module.gradient,
|
||||
)}
|
||||
courseId={courseIdNum}
|
||||
onCreated={() => loadPage()}
|
||||
/>
|
||||
|
||||
<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`}
|
||||
<Dialog
|
||||
open={editingModule !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && savingModuleEdit) return;
|
||||
if (!open && editModuleIconUploadBusy) return;
|
||||
if (!open) closeEditModule();
|
||||
}}
|
||||
>
|
||||
<module.icon
|
||||
className={`h-6 w-6 ${module.id === "m2" ? "text-[#64748B]" : "text-brand-500"}`}
|
||||
<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}
|
||||
{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-grayScale-400 font-medium text-[12px]">
|
||||
{module.description}
|
||||
<p className="text-[12px] font-medium leading-snug text-grayScale-400">
|
||||
{module.description?.trim() ? module.description : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 mt-auto">
|
||||
<div className="mt-auto flex shrink-0 items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-10 rounded-[6px] border-[#9E2891] text-[#9E2891] transition-all text-sm"
|
||||
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${module.id}`,
|
||||
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View Detail
|
||||
</Button>
|
||||
{module.status === "Published" ? (
|
||||
<Button
|
||||
disabled
|
||||
className="flex-1 h-10 rounded-[6px] bg-[#D291BC] text-white opacity-100 cursor-default border-none shadow-none text-sm"
|
||||
>
|
||||
Published
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1 h-10 rounded-[6px] bg-brand-500 text-white shadow-md shadow-brand-500/10 text-sm">
|
||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
|
||||
Publish Practice
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deletingModule && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||
<h2 className="text-lg font-bold text-grayScale-700">
|
||||
Delete module
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
!deletingModuleInFlight && setDeletingModule(null)
|
||||
}
|
||||
disabled={deletingModuleInFlight}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-700">
|
||||
{deletingModule.name}
|
||||
</span>
|
||||
? This cannot be undone. Related content may be affected
|
||||
depending on your backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeletingModule(null)}
|
||||
disabled={deletingModuleInFlight}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
||||
disabled={deletingModuleInFlight}
|
||||
onClick={() => void handleConfirmDeleteModule()}
|
||||
>
|
||||
{deletingModuleInFlight ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,24 +265,33 @@ 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">
|
||||
<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 to group courses by learner level
|
||||
Create a learning program via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /programs
|
||||
</code>
|
||||
. Thumbnail can be a URL or a file uploaded through{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Gradient Divider */}
|
||||
|
|
@ -83,15 +311,26 @@ export function LearnEnglishPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6 p-8 pt-4">
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleCreateProgram();
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Program Name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Beginner"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="e.g. Intermediate Track"
|
||||
className="h-12 rounded-xl ring-0"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -99,62 +338,90 @@ export function LearnEnglishPage() {
|
|||
<label className="text-[15px] text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Short description explaining who this program is for"
|
||||
className="h-12 rounded-xl"
|
||||
<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 ">
|
||||
<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="Upload icon"
|
||||
alt=""
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
Click to upload
|
||||
{createUploadingThumbnail ? "Uploading…" : "Click to upload"}
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or drag and drop
|
||||
or paste a URL below
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
JPG, PNG (max 5 MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{createThumbnail.trim() ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={createThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-28 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={createThumbnail}
|
||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
placeholder="https://…"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<DialogClose asChild>
|
||||
<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>
|
||||
</DialogClose>
|
||||
<Button className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
Create Program
|
||||
<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,31 +444,93 @@ 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}
|
||||
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>
|
||||
<Link to={`/new-content/learn-english/${level.id}/courses`}>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{programs.map((program) => (
|
||||
<Card
|
||||
key={program.id}
|
||||
className="group relative w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div
|
||||
className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${program.name}`}
|
||||
onClick={() => openEdit(program)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${program.name}`}
|
||||
onClick={() => setDeletingProgram(program)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center"
|
||||
style={
|
||||
program.thumbnail?.trim()
|
||||
? {
|
||||
backgroundImage: `url(${program.thumbnail.trim()})`,
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
|
||||
<div className="flex-1 min-h-0">
|
||||
<h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
|
||||
{program.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-grayScale-500 line-clamp-4">
|
||||
{program.description?.trim()
|
||||
? program.description
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/new-content/learn-english/${program.id}/courses`}
|
||||
className="mt-4 block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
View Courses
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
|
|
@ -211,6 +540,167 @@ export function LearnEnglishPage() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { ArrowLeft, Plus, FileText } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
|
|
@ -8,41 +10,297 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} 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 {
|
||||
createProgramCourse,
|
||||
deleteTopLevelCourse,
|
||||
getLearningPrograms,
|
||||
getProgramCourses,
|
||||
updateTopLevelCourse,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import type {
|
||||
LearningProgramListItem,
|
||||
ProgramCourseListItem,
|
||||
} from "../../types/course.types";
|
||||
|
||||
export function ProgramCoursesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { level } = useParams<{ level: string }>();
|
||||
/** Route segment is the numeric program id (see Learn English program cards). */
|
||||
const { level: programIdParam } = useParams<{ level: string }>();
|
||||
const programId = Number(programIdParam);
|
||||
|
||||
const courses = [
|
||||
{
|
||||
id: "a1",
|
||||
title: "A1",
|
||||
description:
|
||||
"Learn basic English words, phrases, and simple sentences for daily situations.",
|
||||
stats: {
|
||||
modules: 3,
|
||||
videos: 15,
|
||||
practices: 18,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
title: "A2",
|
||||
description:
|
||||
"Build on basic skills with longer sentences, and practical conversations.",
|
||||
stats: {
|
||||
modules: 3,
|
||||
videos: 15,
|
||||
practices: 18,
|
||||
},
|
||||
},
|
||||
];
|
||||
const [program, setProgram] = useState<LearningProgramListItem | null>(null);
|
||||
const [courses, setCourses] = useState<ProgramCourseListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [deletingCourse, setDeletingCourse] = useState<ProgramCourseListItem | null>(
|
||||
null,
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const [editingCourse, setEditingCourse] = useState<ProgramCourseListItem | null>(
|
||||
null,
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const programIdValid = Number.isFinite(programId) && programId >= 1;
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!Number.isFinite(programId) || programId < 1) {
|
||||
setError("Invalid program");
|
||||
setLoading(false);
|
||||
setCourses([]);
|
||||
setProgram(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [coursesRes, programsRes] = await Promise.all([
|
||||
getProgramCourses(programId, { limit: 100, offset: 0 }),
|
||||
getLearningPrograms({ limit: 100, offset: 0 }),
|
||||
]);
|
||||
|
||||
const programRows = programsRes.data?.data?.programs;
|
||||
const list = Array.isArray(programRows) ? programRows : [];
|
||||
const found = list.find((p) => p.id === programId) ?? null;
|
||||
setProgram(found);
|
||||
|
||||
const raw = coursesRes.data?.data?.courses;
|
||||
const courseList = Array.isArray(raw) ? raw : [];
|
||||
const sorted = [...courseList].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
);
|
||||
setCourses(sorted);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Failed to load courses");
|
||||
setCourses([]);
|
||||
setProgram(null);
|
||||
toast.error("Could not load courses", {
|
||||
description: "Check your connection or try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [programId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleConfirmDeleteCourse = async () => {
|
||||
if (!deletingCourse) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTopLevelCourse(deletingCourse.id);
|
||||
toast.success("Course deleted");
|
||||
setDeletingCourse(null);
|
||||
await loadData();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to delete course";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditCourse = (course: ProgramCourseListItem) => {
|
||||
setEditingCourse(course);
|
||||
setEditName(course.name ?? "");
|
||||
setEditDescription(course.description?.trim() ?? "");
|
||||
setEditThumbnail(
|
||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
||||
);
|
||||
};
|
||||
|
||||
const closeEditCourse = () => {
|
||||
setEditingCourse(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditThumbnail("");
|
||||
setUploadingEditThumbnail(false);
|
||||
if (editThumbnailFileInputRef.current) {
|
||||
editThumbnailFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCourseThumbnailFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 5 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
||||
return;
|
||||
}
|
||||
setUploadingEditThumbnail(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
setEditThumbnail(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setUploadingEditThumbnail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEditCourse = async () => {
|
||||
if (!editingCourse) return;
|
||||
const name = editName.trim();
|
||||
if (!name) {
|
||||
toast.error("Course name is required");
|
||||
return;
|
||||
}
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateTopLevelCourse(editingCourse.id, {
|
||||
name,
|
||||
description: editDescription.trim(),
|
||||
thumbnail: editThumbnail.trim(),
|
||||
});
|
||||
toast.success("Course updated");
|
||||
closeEditCourse();
|
||||
await loadData();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update course";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCreateCourseForm = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateThumbnail("");
|
||||
setCreateUploadingThumbnail(false);
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
createThumbnailFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCourseDialogOpenChange = (open: boolean) => {
|
||||
if (!open && (createSaving || createUploadingThumbnail)) return;
|
||||
clearCreateCourseForm();
|
||||
setCreateCourseOpen(open);
|
||||
};
|
||||
|
||||
const handleCreateCourseThumbnailFile = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please choose an image file");
|
||||
return;
|
||||
}
|
||||
const maxBytes = 5 * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
||||
return;
|
||||
}
|
||||
setCreateUploadingThumbnail(true);
|
||||
try {
|
||||
const res = await uploadImageFile(file);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) {
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
setCreateThumbnail(url);
|
||||
toast.success("Thumbnail uploaded");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to upload thumbnail";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setCreateUploadingThumbnail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
if (!programIdValid) return;
|
||||
const name = createName.trim();
|
||||
if (!name) {
|
||||
toast.error("Course name is required");
|
||||
return;
|
||||
}
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await createProgramCourse(programId, {
|
||||
name,
|
||||
description: createDescription.trim(),
|
||||
thumbnail: createThumbnail.trim(),
|
||||
});
|
||||
toast.success("Course created");
|
||||
clearCreateCourseForm();
|
||||
setCreateCourseOpen(false);
|
||||
await loadData();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create course";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setCreateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const programTitle = !programIdValid
|
||||
? "Program not found"
|
||||
: program?.name?.trim() || `Program ${programId}`;
|
||||
const programDescription =
|
||||
program?.description?.trim() ||
|
||||
(!loading && programIdValid && !program
|
||||
? "Program details are unavailable. You can still browse courses below if they loaded."
|
||||
: "");
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pt-10">
|
||||
|
|
@ -58,17 +316,30 @@ export function ProgramCoursesPage() {
|
|||
{/* Header section */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-grayScale-700 capitalize">
|
||||
{level || "Program"}
|
||||
<h1 className="text-3xl font-bold tracking-tight text-grayScale-700">
|
||||
{programTitle}
|
||||
</h1>
|
||||
{programDescription ? (
|
||||
<p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400">
|
||||
Designed for learners starting from scratch. Focuses on simple
|
||||
grammar, and everyday communication.
|
||||
{programDescription}
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<img
|
||||
src={spinnerSrc}
|
||||
alt=""
|
||||
className="h-6 w-6 animate-spin"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link to={`/new-content/learn-english/${level}/courses/add-practice`}>
|
||||
{programIdValid ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||
|
|
@ -78,20 +349,35 @@ export function ProgramCoursesPage() {
|
|||
</Button>
|
||||
</Link>
|
||||
|
||||
<Dialog>
|
||||
<Dialog
|
||||
open={createCourseOpen}
|
||||
onOpenChange={handleCreateCourseDialogOpenChange}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
>
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Add Courses
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl gap-0 border-none p-0">
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden border-none p-0">
|
||||
<div className="shrink-0">
|
||||
<DialogHeader className="p-8 pb-4">
|
||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
||||
Add New Course
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a CEFR-aligned course inside this program.
|
||||
Create a course via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /programs/:program_id/courses
|
||||
</code>
|
||||
. Thumbnail can be a URL or a file from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -112,80 +398,128 @@ export function ProgramCoursesPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6 p-8 pt-4">
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleCreateCourse();
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Course Name
|
||||
</label>
|
||||
<Input placeholder="e.g. A1" className="h-12 rounded-xl" />
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder="e.g. Introduction to German A1"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Brief overview of what learners will achieve in this course"
|
||||
className="h-12 rounded-xl"
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Short summary of the course"
|
||||
rows={3}
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Course Order
|
||||
</label>
|
||||
<Select className="h-12 rounded-xl">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium 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">
|
||||
<input
|
||||
ref={createThumbnailFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative w-full cursor-pointer rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-left transition-all hover:border-[#9E289180] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() =>
|
||||
createThumbnailFileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={uploadIcon}
|
||||
alt="Upload icon"
|
||||
alt=""
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
Click to upload
|
||||
{createUploadingThumbnail
|
||||
? "Uploading…"
|
||||
: "Click to upload"}
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">
|
||||
or drag and drop
|
||||
or paste a URL below
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||
JPG, PNG (max 5 MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{createThumbnail.trim() ? (
|
||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={createThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-28 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={createThumbnail}
|
||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
placeholder="https://…"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<DialogClose asChild>
|
||||
<div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
onClick={() => handleCreateCourseDialogOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600">
|
||||
Create Course
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
>
|
||||
{createSaving ? "Creating…" : "Create Course"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -204,52 +538,113 @@ export function ProgramCoursesPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="flex flex-warp gap-10 ">
|
||||
{courses.map((course) => (
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
) : error && courses.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => void loadData()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : courses.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No courses in this program yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Add courses using the button above when the flow is connected to the
|
||||
API.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{courses.map((course) => {
|
||||
const modules = course.modules_count ?? 0;
|
||||
const videos = course.videos_count ?? 0;
|
||||
const practices = course.practices_count ?? 0;
|
||||
const thumbnailSrc =
|
||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "";
|
||||
return (
|
||||
<Card
|
||||
key={course.id}
|
||||
className="group w-[290px] overflow-hidden border border-grayScale-100 shadow-soft transition-all duration-300 hover:shadow-lg"
|
||||
className="group relative w-[290px] overflow-hidden border border-grayScale-100 shadow-soft transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
{/* Gradient Header */}
|
||||
<div
|
||||
className="h-32 w-full"
|
||||
style={{
|
||||
className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||
aria-label={`Edit ${course.name}`}
|
||||
onClick={() => openEditCourse(course)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||
aria-label={`Delete ${course.name}`}
|
||||
onClick={() => setDeletingCourse(course)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center"
|
||||
style={
|
||||
thumbnailSrc
|
||||
? {
|
||||
backgroundImage: `url(${thumbnailSrc})`,
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
||||
}}
|
||||
}
|
||||
}
|
||||
/>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-bold text-grayScale-700">
|
||||
{course.title}
|
||||
{course.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-grayScale-500 line-clamp-2">
|
||||
{course.description}
|
||||
{course.description?.trim() ? course.description : "—"}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="my-6 grid grid-cols-3 gap-4 border-y border-grayScale-50 py-4">
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
{course.stats.modules}
|
||||
{modules}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
Modules
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
{course.stats.videos}
|
||||
{videos}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
Videos
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-bold text-grayScale-700">
|
||||
{course.stats.practices}
|
||||
{practices}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
Practices
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -261,7 +656,7 @@ export function ProgramCoursesPage() {
|
|||
className="h-10 flex-1 rounded-[6px] border-brand-500 text-[13px] font-semibold text-brand-500 "
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/${course.title.toLowerCase()}`,
|
||||
`/new-content/learn-english/${programIdParam}/courses/${course.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
@ -273,8 +668,168 @@ export function ProgramCoursesPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={editingCourse !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
||||
if (!open) closeEditCourse();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and thumbnail. Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /courses/:id
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Course name"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[100px] resize-y rounded-xl"
|
||||
placeholder="Short summary"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Thumbnail
|
||||
</label>
|
||||
<input
|
||||
ref={editThumbnailFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(e) => void handleEditCourseThumbnailFile(e)}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
||||
>
|
||||
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
|
||||
</Button>
|
||||
{editThumbnail.trim() ? (
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={editThumbnail.trim()}
|
||||
alt=""
|
||||
className="h-24 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={editThumbnail}
|
||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="Or paste image URL (https://…)"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEditCourse}
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
onClick={() => void handleSaveEditCourse()}
|
||||
>
|
||||
{savingEdit ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{deletingCourse && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
||||
<h2 className="text-lg font-bold text-grayScale-700">Delete course</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !deleting && setDeletingCourse(null)}
|
||||
disabled={deleting}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-grayScale-700">
|
||||
{deletingCourse.name}
|
||||
</span>
|
||||
? This cannot be undone. Related modules and content may be
|
||||
affected depending on your backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeletingCourse(null)}
|
||||
disabled={deleting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
||||
disabled={deleting}
|
||||
onClick={() => void handleConfirmDeleteCourse()}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,28 +9,106 @@ 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) {
|
||||
export function AddModuleModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
courseId,
|
||||
onCreated,
|
||||
}: AddModuleModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
setSubmitting(false);
|
||||
setIconUploadBusy(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const resetAndClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
setIconUploadBusy(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && (submitting || iconUploadBusy)) return;
|
||||
if (!open) {
|
||||
resetAndClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("Module name is required");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(courseId) || courseId < 1) {
|
||||
toast.error("Invalid course");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTopLevelCourseModule(courseId, {
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
icon: icon.trim(),
|
||||
});
|
||||
toast.success("Module created");
|
||||
if (onCreated) {
|
||||
await onCreated();
|
||||
}
|
||||
resetAndClose();
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to create module";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={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">
|
||||
<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 to organize videos and practices.
|
||||
Create a module with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /courses/:courseId/modules
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
|
|
@ -45,15 +123,23 @@ export function AddModuleModal({ isOpen, onClose }: AddModuleModalProps) {
|
|||
/>
|
||||
</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"
|
||||
<ModuleIconUploadField
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
disabled={submitting}
|
||||
onUploadBusyChange={setIconUploadBusy}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-bold text-[#9E2891]">
|
||||
Click to upload
|
||||
</span>{" "}
|
||||
<span className="text-grayScale-500">or drag and drop</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
||||
JPG, PNG (MAX 1 MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user