pre-exam courses UI integration

This commit is contained in:
Yared Yemane 2026-05-05 08:10:20 -07:00
parent a9216c4f4b
commit a6ccfba733
15 changed files with 3582 additions and 354 deletions

View File

@ -78,6 +78,26 @@ import type {
CreateTopLevelCourseModuleResponse,
CreateProgramCourseRequest,
CreateProgramCourseResponse,
CreateExamPrepCatalogCourseRequest,
CreateExamPrepCatalogCourseResponse,
GetExamPrepCatalogCoursesResponse,
UpdateExamPrepCatalogCourseRequest,
UpdateExamPrepCatalogCourseResponse,
CreateExamPrepCatalogUnitRequest,
CreateExamPrepCatalogUnitResponse,
UpdateExamPrepCatalogUnitRequest,
UpdateExamPrepCatalogUnitResponse,
GetExamPrepCatalogUnitsResponse,
CreateExamPrepUnitModuleRequest,
CreateExamPrepUnitModuleResponse,
UpdateExamPrepUnitModuleRequest,
UpdateExamPrepUnitModuleResponse,
GetExamPrepUnitModulesResponse,
CreateExamPrepModuleLessonRequest,
CreateExamPrepModuleLessonResponse,
UpdateExamPrepModuleLessonRequest,
UpdateExamPrepModuleLessonResponse,
GetExamPrepModuleLessonsResponse,
GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse,
CreateParentLinkedPracticeRequest,
@ -447,6 +467,128 @@ export const createProgramCourse = (
data: CreateProgramCourseRequest,
) => http.post<CreateProgramCourseResponse>(`/programs/${programId}/courses`, data)
/** English proficiency catalog course — POST /exam-prep/catalog-courses */
export const createExamPrepCatalogCourse = (
data: CreateExamPrepCatalogCourseRequest,
) => http.post<CreateExamPrepCatalogCourseResponse>("/exam-prep/catalog-courses", data)
/** English proficiency catalog courses — GET /exam-prep/catalog-courses */
export const getExamPrepCatalogCourses = (params?: { limit?: number; offset?: number }) =>
http.get<GetExamPrepCatalogCoursesResponse>("/exam-prep/catalog-courses", { params })
/** English proficiency catalog course — PUT /exam-prep/catalog-courses/:catalogCourseId */
export const updateExamPrepCatalogCourse = (
catalogCourseId: number,
data: UpdateExamPrepCatalogCourseRequest,
) =>
http.put<UpdateExamPrepCatalogCourseResponse>(
`/exam-prep/catalog-courses/${catalogCourseId}`,
data,
)
/** English proficiency catalog course — DELETE /exam-prep/catalog-courses/:catalogCourseId */
export const deleteExamPrepCatalogCourse = (catalogCourseId: number) =>
http.delete(`/exam-prep/catalog-courses/${catalogCourseId}`)
/** English proficiency catalog unit — POST /exam-prep/catalog-courses/:catalogCourseId/units */
export const createExamPrepCatalogUnit = (
catalogCourseId: number,
data: CreateExamPrepCatalogUnitRequest,
) =>
http.post<CreateExamPrepCatalogUnitResponse>(
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
data,
)
/** English proficiency catalog units — GET /exam-prep/catalog-courses/:catalogCourseId/units */
export const getExamPrepCatalogUnits = (
catalogCourseId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetExamPrepCatalogUnitsResponse>(
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
{ params },
)
/** English proficiency unit — PUT /exam-prep/units/:unitId */
export const updateExamPrepCatalogUnit = (
unitId: number,
data: UpdateExamPrepCatalogUnitRequest,
) => http.put<UpdateExamPrepCatalogUnitResponse>(`/exam-prep/units/${unitId}`, data)
/** English proficiency unit — DELETE /exam-prep/units/:unitId */
export const deleteExamPrepCatalogUnit = (unitId: number) =>
http.delete(`/exam-prep/units/${unitId}`)
/** English proficiency unit modules — POST /exam-prep/units/:unitId/modules */
export const createExamPrepUnitModule = (
unitId: number,
data: CreateExamPrepUnitModuleRequest,
) =>
http.post<CreateExamPrepUnitModuleResponse>(
`/exam-prep/units/${unitId}/modules`,
data,
)
/** English proficiency unit modules — GET /exam-prep/units/:unitId/modules */
export const getExamPrepUnitModules = (
unitId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetExamPrepUnitModulesResponse>(`/exam-prep/units/${unitId}/modules`, {
params,
})
/** English proficiency module — PUT /exam-prep/modules/:moduleId */
export const updateExamPrepUnitModule = (
moduleId: number,
data: UpdateExamPrepUnitModuleRequest,
) =>
http.put<UpdateExamPrepUnitModuleResponse>(
`/exam-prep/modules/${moduleId}`,
data,
)
/** English proficiency module — DELETE /exam-prep/modules/:moduleId */
export const deleteExamPrepUnitModule = (moduleId: number) =>
http.delete(`/exam-prep/modules/${moduleId}`)
/** English proficiency module lessons — GET /exam-prep/modules/:moduleId/lessons */
export const getExamPrepModuleLessons = (
moduleId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetExamPrepModuleLessonsResponse>(
`/exam-prep/modules/${moduleId}/lessons`,
{
params,
},
)
/** English proficiency module lesson — POST /exam-prep/modules/:moduleId/lessons */
export const createExamPrepModuleLesson = (
moduleId: number,
data: CreateExamPrepModuleLessonRequest,
) =>
http.post<CreateExamPrepModuleLessonResponse>(
`/exam-prep/modules/${moduleId}/lessons`,
data,
)
/** English proficiency lesson — PUT /exam-prep/lessons/:lessonId */
export const updateExamPrepModuleLesson = (
lessonId: number,
data: UpdateExamPrepModuleLessonRequest,
) =>
http.put<UpdateExamPrepModuleLessonResponse>(
`/exam-prep/lessons/${lessonId}`,
data,
)
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
export const deleteExamPrepModuleLesson = (lessonId: number) =>
http.delete(`/exam-prep/lessons/${lessonId}`)
/** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data)

View File

@ -44,6 +44,36 @@ export interface UploadMediaFromUrlPayload extends UploadMediaOptions {
sourceUrl: string
}
const GOOGLE_DRIVE_HOSTS = new Set([
"drive.google.com",
"www.drive.google.com",
])
const getGoogleDriveFileId = (rawUrl: string): string | null => {
try {
const url = new URL(rawUrl.trim())
if (!GOOGLE_DRIVE_HOSTS.has(url.hostname.toLowerCase())) return null
const fromQuery = url.searchParams.get("id")?.trim()
if (fromQuery) return fromQuery
const fileMatch = url.pathname.match(/\/file\/d\/([^/]+)/i)
return fileMatch?.[1]?.trim() || null
} catch {
return null
}
}
const normalizeSourceUrlForUpload = (
mediaType: UploadMediaType,
sourceUrl: string,
): string => {
const trimmed = sourceUrl.trim()
if (mediaType !== "image") return trimmed
const fileId = getGoogleDriveFileId(trimmed)
if (!fileId) return trimmed
// Use Drive thumbnail endpoint so backend receives actual image bytes, not HTML viewer.
return `https://drive.google.com/thumbnail?id=${encodeURIComponent(fileId)}&sz=w2048`
}
export const uploadMediaFile = (
mediaType: UploadMediaType,
file: File,
@ -67,7 +97,7 @@ export const uploadMediaFromUrl = (
) =>
http.post<UploadMediaResponse>("/files/upload", {
media_type: mediaType,
source_url: payload.sourceUrl,
source_url: normalizeSourceUrlForUpload(mediaType, payload.sourceUrl),
...(mediaType === "video" && payload.title ? { title: payload.title } : {}),
...(mediaType === "video" && payload.description ? { description: payload.description } : {}),
})

View File

@ -14,6 +14,8 @@ import { Select } from "../ui/select"
import { Button } from "../ui/button"
import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils"
import { ResolvedAudio } from "../media/ResolvedAudio"
import { ResolvedImage } from "../media/ResolvedImage"
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
@ -815,7 +817,7 @@ export function PracticeQuestionEditorFields({
disabled={controlsDisabled}
/>
{voicePreviewUrl ? (
<audio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
<ResolvedAudio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
) : null}
</div>
</div>
@ -862,7 +864,7 @@ export function PracticeQuestionEditorFields({
disabled={controlsDisabled}
/>
{samplePreviewUrl ? (
<audio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
<ResolvedAudio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
) : null}
</div>
</div>
@ -898,7 +900,7 @@ export function PracticeQuestionEditorFields({
disabled={controlsDisabled}
/>
{imagePreviewUrl ? (
<img
<ResolvedImage
src={imagePreviewUrl}
alt=""
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"

View File

@ -0,0 +1,33 @@
import { useEffect, useState, type AudioHTMLAttributes } from "react"
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
type ResolvedAudioProps = AudioHTMLAttributes<HTMLAudioElement> & {
src?: string | null
}
export function ResolvedAudio({ src, ...audioProps }: ResolvedAudioProps) {
const [resolvedSrc, setResolvedSrc] = useState("")
useEffect(() => {
let cancelled = false
;(async () => {
const raw = (src ?? "").trim()
if (!raw) {
setResolvedSrc("")
return
}
try {
const next = await resolveDisplayMediaUrl(raw)
if (!cancelled) setResolvedSrc(next || raw)
} catch {
if (!cancelled) setResolvedSrc(raw)
}
})()
return () => {
cancelled = true
}
}, [src])
if (!resolvedSrc) return null
return <audio {...audioProps} src={resolvedSrc} />
}

View File

@ -0,0 +1,35 @@
import { useEffect, useState, type ImgHTMLAttributes } from "react"
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
type ResolvedImageProps = ImgHTMLAttributes<HTMLImageElement> & {
src?: string | null
fallbackSrc?: string
}
export function ResolvedImage({ src, fallbackSrc, ...imgProps }: ResolvedImageProps) {
const [resolvedSrc, setResolvedSrc] = useState("")
useEffect(() => {
let cancelled = false
;(async () => {
const raw = (src ?? "").trim()
if (!raw) {
setResolvedSrc("")
return
}
try {
const next = await resolveDisplayMediaUrl(raw)
if (!cancelled) setResolvedSrc(next || raw)
} catch {
if (!cancelled) setResolvedSrc(raw)
}
})()
return () => {
cancelled = true
}
}, [src])
const finalSrc = resolvedSrc || fallbackSrc || ""
if (!finalSrc) return null
return <img {...imgProps} src={finalSrc} />
}

40
src/lib/mediaUrl.ts Normal file
View File

@ -0,0 +1,40 @@
import { refreshFileUrl, resolveFileUrl } from "../api/files.api"
const HTTP_REGEX = /^https?:\/\//i
export function isHttpUrl(value: string): boolean {
return HTTP_REGEX.test(value.trim())
}
export function isSignedMinioUrl(value: string): boolean {
const trimmed = value.trim()
if (!isHttpUrl(trimmed)) return false
try {
const url = new URL(trimmed)
return (
url.host === "s3.yimaruacademy.com" &&
(url.searchParams.has("X-Amz-Signature") || url.searchParams.has("X-Amz-Expires"))
)
} catch {
return false
}
}
export async function resolveDisplayMediaUrl(value: string): Promise<string> {
const trimmed = value.trim()
if (!trimmed) return ""
if (isHttpUrl(trimmed)) {
if (!isSignedMinioUrl(trimmed)) return trimmed
try {
const refreshed = await refreshFileUrl(trimmed)
const refreshedUrl = refreshed.data?.data?.url?.trim()
return refreshedUrl || trimmed
} catch {
return trimmed
}
}
const resolved = await resolveFileUrl(trimmed)
return resolved.data?.data?.url?.trim() || ""
}

View File

@ -1,4 +1,4 @@
import { resolveFileUrl } from "../api/files.api"
import { resolveDisplayMediaUrl } from "./mediaUrl"
export function normalizeObjectKey(value: string): string {
const trimmed = value.trim()
@ -12,10 +12,7 @@ export function normalizeObjectKey(value: string): string {
}
export async function resolveMediaPreviewUrl(value: string): Promise<string> {
if (!value.trim()) return ""
if (value.startsWith("http://") || value.startsWith("https://")) return value
const key = normalizeObjectKey(value)
if (!key) return ""
const res = await resolveFileUrl(key)
return res.data?.data?.url ?? ""
const normalized = normalizeObjectKey(value)
if (!normalized) return ""
return resolveDisplayMediaUrl(normalized)
}

View File

@ -13,7 +13,15 @@ export function toVimeoEmbedUrl(rawUrl: string): string | null {
const segments = parsed.pathname.split("/").filter(Boolean);
const videoId = segments.find((segment) => /^\d+$/.test(segment));
if (!videoId) return null;
const hash = parsed.searchParams.get("h");
// Vimeo private/unlisted links often come as /<videoId>/<hash> instead of ?h=<hash>.
const hashFromPath = (() => {
const videoIdx = segments.findIndex((segment) => segment === videoId);
if (videoIdx < 0) return null;
const maybeHash = segments[videoIdx + 1];
if (!maybeHash) return null;
return /^[a-zA-Z0-9]+$/.test(maybeHash) ? maybeHash : null;
})();
const hash = parsed.searchParams.get("h") || hashFromPath;
return hash
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
: `https://player.vimeo.com/video/${videoId}`;

View File

@ -348,23 +348,40 @@ export function CourseDetailPage() {
Try again
</Button>
</div>
</div>
<AddModuleModal
isOpen={isAddModuleOpen}
onClose={() => setIsAddModuleOpen(false)}
/>
{/* Gradient Divider */}
{/* Gradient Grid */}
<div className="flex flex-warp gap-10">
{MODULES.map((module) => (
<Card
key={module.id}
className="group overflow-hidden border w-[330px] border-grayScale-50 shadow-sm hover:shadow-lg transition-all duration-300 rounded-[16px] bg-white flex flex-col h-full"
>
{/* Gradient Banner */}
) : (
<>
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
{displayTitle}
</h1>
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
{displayDescription}
</p>
</div>
<div className="flex items-center gap-4">
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
<Calendar className="h-4 w-4" />
Add Practice
</Button>
<Button
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
onClick={() => setIsAddModuleOpen(true)}
>
<Plus className="h-4 w-4" />
Add Module
</Button>
</div>
</div>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"

View File

@ -1,3 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
@ -6,12 +7,15 @@ import {
LayoutGrid,
PlayCircle,
ClipboardCheck,
Pencil,
Trash2,
ChevronRight,
ArrowRight,
X,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@ -22,6 +26,15 @@ import {
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import uploadIcon from "../../assets/icons/upload.png";
import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import {
createExamPrepCatalogUnit,
updateExamPrepCatalogUnit,
deleteExamPrepCatalogUnit,
getExamPrepCatalogUnits,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
export function CourseManagementPage() {
const navigate = useNavigate();
@ -29,6 +42,38 @@ export function CourseManagementPage() {
programType: string;
courseId: string;
}>();
const catalogCourseId = Number(courseId);
const [addUnitOpen, setAddUnitOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [units, setUnits] = useState<
Array<{
id: number;
name: string;
description: string;
thumbnail: string;
sortOrder: number;
modules: number;
lessons: number;
practices: number;
gradient: string;
}>
>([]);
const [unitsLoading, setUnitsLoading] = useState(false);
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null);
const [deletingUnit, setDeletingUnit] = useState(false);
// Mock data for display titles
const courseTitles: Record<string, string> = {
@ -39,41 +84,289 @@ export function CourseManagementPage() {
const courseDisplayName =
courseTitles[courseId || ""] || "Duolingo English Test";
const units = [
{
id: "unit1",
name: "Greetings & Introductions",
description:
"Learn basic greetings, self-introductions, and polite expressions in everyday situations.",
modules: 3,
videos: 9,
practices: 9,
gradient:
"linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
},
{
id: "unit2",
name: "Speaking",
description:
"Core speaking practice and skill building for natural pronunciation and fluency.",
modules: 3,
videos: 9,
practices: 9,
gradient:
"linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)",
},
{
id: "unit3",
name: "Reading",
description:
"Reading comprehension and vocabulary improvement through various text types.",
modules: 3,
videos: 9,
practices: 9,
gradient:
"linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)",
},
];
const loadUnits = useCallback(async () => {
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
setUnits([]);
return;
}
setUnitsLoading(true);
try {
const response = await getExamPrepCatalogUnits(catalogCourseId, {
limit: 20,
offset: 0,
});
const rows = response.data?.data?.units;
const list = Array.isArray(rows) ? rows : [];
setUnits(
list.map((row, index) => ({
id: Number(row.id),
name: row.name?.trim() || `Unit ${row.id}`,
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
modules: Number(row.modules_count ?? 0),
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
practices: Number(row.practices_count ?? 0),
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
})),
);
} catch (error) {
console.error(error);
toast.error("Failed to load units");
setUnits([]);
} finally {
setUnitsLoading(false);
}
}, [catalogCourseId]);
useEffect(() => {
void loadUnits();
}, [loadUnits]);
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
const isMinioUrl = (value: string) => {
try {
const url = new URL(value);
return url.host === "s3.yimaruacademy.com";
} catch {
return false;
}
};
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) return "";
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
const uploaded = await uploadImageFile(trimmed);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
return uploadedUrl;
};
const clearCreateUnitForm = () => {
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
}
};
const handleCreateUnitThumbnailFile = 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;
}
setUploadingThumbnail(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 (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const handleCreateUnit = async () => {
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
toast.error("Invalid catalog course");
return;
}
const name = createName.trim();
if (!name) {
toast.error("Unit name is required");
return;
}
setCreating(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
const response = await createExamPrepCatalogUnit(catalogCourseId, {
name,
description: createDescription.trim() || null,
thumbnail: minioThumbnail || null,
});
void response;
await loadUnits();
toast.success("Unit created");
clearCreateUnitForm();
setAddUnitOpen(false);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create unit";
toast.error(message);
} finally {
setCreating(false);
}
};
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setCreateThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingEditThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setEditThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const openEditUnit = (unit: (typeof units)[number]) => {
setEditingUnitId(unit.id);
setEditName(unit.name ?? "");
setEditDescription(unit.description ?? "");
setEditThumbnail(unit.thumbnail ?? "");
setEditSortOrder(String(unit.sortOrder ?? 1));
};
const closeEditUnit = () => {
if (savingEdit || uploadingEditThumbnail) return;
setEditingUnitId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
};
const handleEditUnitThumbnailFile = 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;
}
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 (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditUnit = async () => {
if (!editingUnitId) return;
const name = editName.trim();
if (!name) {
toast.error("Unit name is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
return;
}
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepCatalogUnit(editingUnitId, {
name,
description: editDescription.trim() || null,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
});
await loadUnits();
toast.success("Unit updated");
closeEditUnit();
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update unit";
toast.error(message);
} finally {
setSavingEdit(false);
}
};
const handleDeleteUnit = async () => {
if (!deletingUnitId) return;
setDeletingUnit(true);
try {
await deleteExamPrepCatalogUnit(deletingUnitId);
await loadUnits();
toast.success("Unit deleted");
setDeletingUnitId(null);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete unit";
toast.error(message);
} finally {
setDeletingUnit(false);
}
};
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
@ -98,29 +391,51 @@ export function CourseManagementPage() {
</div>
<div className="flex items-center gap-3 pt-2">
<Dialog>
<Dialog
open={addUnitOpen}
onOpenChange={(open) => {
if (!open && (creating || uploadingThumbnail)) return;
setAddUnitOpen(open);
}}
>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2">
<Plus className="h-5 w-5" />
Add Unit
</Button>
</DialogTrigger>
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
<div className="bg-white">
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Courses
Create Unit
</DialogTitle>
</DialogHeader>
<div className="p-8 space-y-8">
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Unit Name
</label>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="e.g. Reading"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating || uploadingThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Short unit description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating || uploadingThumbnail}
/>
</div>
@ -128,7 +443,20 @@ export function CourseManagementPage() {
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<div className="relative group cursor-pointer">
<input
ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateUnitThumbnailFile(e)}
disabled={creating || uploadingThumbnail}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createThumbnailFileInputRef.current?.click()}
disabled={creating || uploadingThumbnail}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
<div className="mb-4">
<img
@ -139,31 +467,62 @@ export function CourseManagementPage() {
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
Click to upload
{uploadingThumbnail ? "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.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 1 MB)
JPG, PNG (MAX 5 MB)
</p>
</div>
</div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadCreateThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating || uploadingThumbnail}
/>
</div>
</div>
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={creating || uploadingThumbnail}
onClick={clearCreateUnitForm}
>
Cancel
</Button>
</DialogClose>
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
Create Courses
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creating || uploadingThumbnail}
onClick={() => void handleCreateUnit()}
>
{creating ? "Creating..." : "Create Unit"}
</Button>
</div>
</div>
@ -200,16 +559,61 @@ export function CourseManagementPage() {
{/* Grid of Units */}
<div className="flex flex-wrap gap-4 pt-4">
{units.map((unit) => (
{unitsLoading ? (
<p className="text-sm text-grayScale-500">Loading units...</p>
) : units.length === 0 ? (
<div className="w-full 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 units for this course yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first unit to start organizing modules, lessons, and practices.
</p>
</div>
) : (
units.map((unit) => (
<Card
key={unit.id}
className="group flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
className="group relative flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
>
<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"
onClick={() => openEditUnit(unit)}
aria-label={`Edit ${unit.name}`}
>
<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"
onClick={() => setDeletingUnitId(unit.id)}
aria-label={`Delete ${unit.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Gradient Header */}
<div
className="h-36 w-full transition-transform duration-500 "
className="relative h-36 w-full overflow-hidden transition-transform duration-500"
style={{ background: unit.gradient }}
/>
>
{unit.thumbnail ? (
<ResolvedImage
src={unit.thumbnail}
alt={`${unit.name} thumbnail`}
className="h-full w-full object-cover"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
) : null}
</div>
<div className="p-4 flex flex-col flex-1 space-y-6">
<div className="space-y-3 flex-1">
@ -232,7 +636,7 @@ export function CourseManagementPage() {
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
<span className="text-[12px] font-bold">
{unit.videos} Videos
{unit.lessons} Lessons
</span>
</div>
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
@ -257,8 +661,169 @@ export function CourseManagementPage() {
</Button>
</div>
</Card>
))}
))
)}
</div>
<Dialog
open={editingUnitId !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail)) return;
if (!open) closeEditUnit();
}}
>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Edit Unit
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Unit Name</label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditUnitThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editThumbnailFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{editThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={editThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadEditThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={savingEdit || uploadingEditThumbnail}
onClick={closeEditUnit}
>
Cancel
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={savingEdit || uploadingEditThumbnail}
onClick={() => void handleSaveEditUnit()}
>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deletingUnitId !== null}
onOpenChange={(open) => {
if (!open && !deletingUnit) setDeletingUnitId(null);
}}
>
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
<div className="bg-white">
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Delete Unit
</DialogTitle>
</DialogHeader>
<div className="px-6 py-6 text-sm text-grayScale-600">
Are you sure you want to delete this unit? This action cannot be undone.
</div>
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setDeletingUnitId(null)}
disabled={deletingUnit}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={() => void handleDeleteUnit()}
disabled={deletingUnit}
>
{deletingUnit ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,40 +1,30 @@
import { useState } from "react";
import { ArrowLeft, Plus, FileText, MoreVertical, Edit2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils";
import { Card } from "../../components/ui/card";
const MOCK_VIDEOS = [
{
id: "v1",
title: "1.1 Introduction to Formal Greetings",
duration: "08:45",
status: "Draft",
thumbnailColor: "bg-[#CBD5E1]",
},
{
id: "v2",
title: "1.2 Understanding Email Structure",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#DBEAFE]",
},
{
id: "v3",
title: "1.3 Common Business Idioms",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#FEF3C7]",
},
{
id: "v4",
title: "1.4 Video Conference Etiquette",
duration: "08:45",
status: "Published",
thumbnailColor: "bg-[#FCE7F3]",
},
];
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png";
import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import { VideoCard } from "./components/VideoCard";
import {
createExamPrepModuleLesson,
updateExamPrepModuleLesson,
deleteExamPrepModuleLesson,
getExamPrepModuleLessons,
} from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
const MOCK_PRACTICES = [
{
@ -61,19 +51,402 @@ export function CourseModuleDetailPage() {
unitId: string;
moduleId: string;
}>();
const parsedModuleId = Number(moduleId);
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("All");
const [lessonsLoading, setLessonsLoading] = useState(false);
const [lessons, setLessons] = useState<
Array<{
id: number;
title: string;
videoUrl: string;
description: string;
thumbnail: string;
sortOrder: number;
gradient: string;
}>
>([]);
const [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [creatingLesson, setCreatingLesson] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const [uploadingVideo, setUploadingVideo] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const createVideoFileInputRef = useRef<HTMLInputElement>(null);
const [editingLessonId, setEditingLessonId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editVideoUrl, setEditVideoUrl] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
const [uploadingEditVideo, setUploadingEditVideo] = useState(false);
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const editVideoFileInputRef = useRef<HTMLInputElement>(null);
const [deletingLessonId, setDeletingLessonId] = useState<number | null>(null);
const [deletingLesson, setDeletingLesson] = useState(false);
const moduleTitle = "Module 1: Basic Phrases";
const moduleDescription = "Learn essential phrases for daily conversations.";
const content = activeTab === "video" ? MOCK_VIDEOS : MOCK_PRACTICES;
const filteredContent = content.filter((item) => {
if (activeFilter === "All") return true;
if (activeFilter === "Drafts") return item.status === "Draft";
return item.status === activeFilter;
});
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
const isMinioUrl = (value: string) => {
try {
const url = new URL(value);
return url.host === "s3.yimaruacademy.com";
} catch {
return false;
}
};
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) return "";
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
const uploaded = await uploadImageFile(trimmed);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
return uploadedUrl;
};
const loadLessons = useCallback(async () => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
setLessons([]);
return;
}
setLessonsLoading(true);
try {
const response = await getExamPrepModuleLessons(parsedModuleId, {
limit: 20,
offset: 0,
});
const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : [];
setLessons(
list.map((row, index) => ({
id: Number(row.id),
title: row.title?.trim() || `Lesson ${row.id}`,
videoUrl: row.video_url?.trim() || "",
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
})),
);
} catch (error) {
console.error(error);
toast.error("Failed to load lessons");
setLessons([]);
} finally {
setLessonsLoading(false);
}
}, [parsedModuleId]);
useEffect(() => {
if (activeTab !== "video") return;
void loadLessons();
}, [activeTab, loadLessons]);
const clearCreateLessonForm = () => {
setCreateTitle("");
setCreateVideoUrl("");
setCreateThumbnail("");
setCreateDescription("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
}
if (createVideoFileInputRef.current) {
createVideoFileInputRef.current.value = "";
}
};
const handleCreateLessonVideoFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
setUploadingVideo(true);
try {
const res = await uploadVideoFile(file, {
title: createTitle.trim() || "Lesson video",
description: createDescription.trim() || undefined,
});
const finalUrl =
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
if (!finalUrl) throw new Error("Upload did not return a video URL");
setCreateVideoUrl(finalUrl);
toast.success("Video uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(message);
} finally {
setUploadingVideo(false);
}
};
const handleCreateLessonThumbnailFile = 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;
}
setUploadingThumbnail(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 (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setCreateThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const handleCreateLesson = async () => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module");
return;
}
const title = createTitle.trim();
const videoUrl = createVideoUrl.trim();
if (!title) {
toast.error("Lesson title is required");
return;
}
if (!videoUrl) {
toast.error("Video URL is required");
return;
}
setCreatingLesson(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
await createExamPrepModuleLesson(parsedModuleId, {
title,
video_url: videoUrl,
thumbnail: minioThumbnail || null,
description: createDescription.trim() || null,
});
await loadLessons();
toast.success("Lesson created");
clearCreateLessonForm();
setCreateLessonOpen(false);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create lesson";
toast.error(message);
} finally {
setCreatingLesson(false);
}
};
const openEditLesson = (lesson: (typeof lessons)[number]) => {
setEditingLessonId(lesson.id);
setEditTitle(lesson.title ?? "");
setEditVideoUrl(lesson.videoUrl ?? "");
setEditThumbnail(lesson.thumbnail ?? "");
setEditDescription(lesson.description ?? "");
setEditSortOrder(String(lesson.sortOrder ?? 1));
};
const closeEditLesson = () => {
if (savingEdit || uploadingEditThumbnail || uploadingEditVideo) return;
setEditingLessonId(null);
setEditTitle("");
setEditVideoUrl("");
setEditThumbnail("");
setEditDescription("");
setEditSortOrder("1");
};
const handleEditLessonVideoFile = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("video/")) {
toast.error("Please choose a video file");
return;
}
setUploadingEditVideo(true);
try {
const res = await uploadVideoFile(file, {
title: editTitle.trim() || "Lesson video",
description: editDescription.trim() || undefined,
});
const finalUrl =
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
if (!finalUrl) throw new Error("Upload did not return a video URL");
setEditVideoUrl(finalUrl);
toast.success("Video uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload video";
toast.error(message);
} finally {
setUploadingEditVideo(false);
}
};
const handleEditLessonThumbnailFile = 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;
}
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 (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
setUploadingEditThumbnail(true);
try {
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
if (minioUrl && minioUrl !== trimmed) {
setEditThumbnail(minioUrl);
toast.success("Thumbnail uploaded to MinIO");
}
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload URL to MinIO";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditLesson = async () => {
if (!editingLessonId) return;
const title = editTitle.trim();
if (!title) {
toast.error("Lesson title is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
return;
}
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepModuleLesson(editingLessonId, {
title,
video_url: editVideoUrl.trim() || null,
thumbnail: minioThumbnail || null,
description: editDescription.trim() || null,
sort_order: sortOrderNum,
});
await loadLessons();
toast.success("Lesson updated");
closeEditLesson();
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson";
toast.error(message);
} finally {
setSavingEdit(false);
}
};
const handleDeleteLesson = async () => {
if (!deletingLessonId) return;
setDeletingLesson(true);
try {
await deleteExamPrepModuleLesson(deletingLessonId);
await loadLessons();
toast.success("Lesson deleted");
setDeletingLessonId(null);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete lesson";
toast.error(message);
} finally {
setDeletingLesson(false);
}
};
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
@ -110,10 +483,188 @@ export function CourseModuleDetailPage() {
<FileText className="h-5 w-5" />
Attach Practice
</Button>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
<Plus className="h-5 w-5" />
Add Video
</Button>
<Dialog
open={createLessonOpen}
onOpenChange={(open) => {
if (!open && (creatingLesson || uploadingThumbnail || uploadingVideo))
return;
setCreateLessonOpen(open);
}}
>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
<Plus className="h-5 w-5" />
Add Lesson
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Lesson
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Lesson Title
</label>
<Input
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
placeholder="e.g. Intro lesson"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Video URL
</label>
<input
ref={createVideoFileInputRef}
type="file"
accept="video/*"
className="sr-only"
onChange={(e) => void handleCreateLessonVideoFile(e)}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createVideoFileInputRef.current?.click()}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingVideo ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
video from your computer
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
MP4, MOV, WEBM
</p>
</div>
</button>
<Input
value={createVideoUrl}
onChange={(e) => setCreateVideoUrl(e.target.value)}
placeholder="https://example.com/video"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional lesson description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<input
ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateLessonThumbnailFile(e)}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => createThumbnailFileInputRef.current?.click()}
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
or paste a URL below
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => setCreateThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadCreateThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={clearCreateLessonForm}
>
Cancel
</Button>
</DialogClose>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson()}
>
{creatingLesson ? "Creating..." : "Create Lesson"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
@ -128,7 +679,7 @@ export function CourseModuleDetailPage() {
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Video
Lesson
</button>
<button
onClick={() => setActiveTab("practice")}
@ -143,40 +694,245 @@ export function CourseModuleDetailPage() {
</button>
</div>
{/* Filter Bar */}
<div className="bg-white border border-grayScale-100 rounded-[16px] p-4 flex items-center gap-8 shadow-sm">
<div className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest pl-4">
STATUS:
</div>
<div className="flex items-center gap-2">
{["All", "Published", "Drafts", "Archived"].map((filter) => (
<button
key={filter}
onClick={() => setActiveFilter(filter)}
className={cn(
"px-5 py-2 rounded-full text-[13px] font-bold transition-all",
activeFilter === filter
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-grayScale-100 text-grayScale-500 hover:bg-grayScale-200",
)}
>
{filter}
</button>
))}
</div>
</div>
{/* Grid of Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
{filteredContent.map((item) => (
<ContentCard key={item.id} {...item} />
))}
{activeTab === "video" ? (
lessonsLoading ? (
<p className="text-sm text-grayScale-500">Loading lessons...</p>
) : lessons.length === 0 ? (
<div className="col-span-full 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 lessons for this module yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first lesson to start building this module.
</p>
</div>
) : (
lessons.map((lesson) => (
<VideoCard
key={lesson.id}
title={lesson.title}
thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient}
hoverModuleActions
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}
/>
))
)
) : (
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
)}
</div>
<Dialog
open={editingLessonId !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail || uploadingEditVideo))
return;
if (!open) closeEditLesson();
}}
>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Edit Lesson
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Lesson Title</label>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Video URL</label>
<input
ref={editVideoFileInputRef}
type="file"
accept="video/*"
className="sr-only"
onChange={(e) => void handleEditLessonVideoFile(e)}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editVideoFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditVideo ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
video from your computer
</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
MP4, MOV, WEBM
</p>
</div>
</button>
<Input
value={editVideoUrl}
onChange={(e) => setEditVideoUrl(e.target.value)}
placeholder="https://example.com/video"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditLessonThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
<button
type="button"
className="relative group w-full cursor-pointer"
onClick={() => editThumbnailFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
>
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
<div className="mb-4">
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{editThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={editThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
onPaste={(event) => {
const pasted = event.clipboardData?.getData("text")?.trim();
if (!pasted) return;
setTimeout(() => {
void autoUploadEditThumbnailUrl(pasted);
}, 0);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
onClick={closeEditLesson}
>
Cancel
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
onClick={() => void handleSaveEditLesson()}
>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deletingLessonId !== null}
onOpenChange={(open) => {
if (!open && !deletingLesson) setDeletingLessonId(null);
}}
>
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
<div className="bg-white">
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Delete Lesson
</DialogTitle>
</DialogHeader>
<div className="px-6 py-6 text-sm text-grayScale-600">
Are you sure you want to delete this lesson? This action cannot be undone.
</div>
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setDeletingLessonId(null)}
disabled={deletingLesson}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={() => void handleDeleteLesson()}
disabled={deletingLesson}
>
{deletingLesson ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
function ContentCard({
function PracticeCard({
title,
duration,
status,
@ -214,9 +970,7 @@ function ContentCard({
/>
{status}
</div>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-300 hover:text-grayScale-600 transition-colors">
<MoreVertical className="h-5 w-5" />
</button>
<div />
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
@ -224,11 +978,7 @@ function ContentCard({
</h3>
<div className="pt-2 grid grid-cols-1 gap-2 mt-auto">
<Button
variant="outline"
className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold flex items-center justify-center gap-2 text-xs hover:bg-grayScale-25"
>
<Edit2 className="h-4 w-4" />
<Button variant="outline" className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold text-xs">
Edit
</Button>
<Button
@ -246,3 +996,4 @@ function ContentCard({
</Card>
);
}

View File

@ -1,3 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
@ -6,11 +7,13 @@ import {
ClipboardList,
ListChecks,
ChevronRight,
Pencil,
Trash2,
X,
Upload,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@ -20,12 +23,51 @@ import {
DialogClose,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import {
createExamPrepCatalogCourse,
getExamPrepCatalogCourses,
updateExamPrepCatalogCourse,
deleteExamPrepCatalogCourse,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import uploadIcon from "../../assets/icons/upload.png";
export function ProgramDetailPage() {
const navigate = useNavigate();
const { programType } = useParams<{ programType: string }>();
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [createdCourses, setCreatedCourses] = useState<
{
id: number;
name: string;
description: string;
thumbnail?: string | null;
sortOrder: number;
unitsCount: number;
modulesCount: number;
lessonsCount: number;
}[]
>([]);
const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [deletingCourseId, setDeletingCourseId] = useState<number | null>(null);
const [deletingCourse, setDeletingCourse] = useState(false);
// Mock data for "proficiency" program type
const programs: Record<string, any> = {
@ -33,45 +75,7 @@ export function ProgramDetailPage() {
title: "English Proficiency Exams",
description:
"Manage exam-based learning programs such as Duolingo and IELTS.",
courses: [
{
id: "duolingo",
name: "Duolingo English Test",
description:
"Adaptive exam-style practice for speaking, writing, reading, and listening.",
coursesCount: 6,
questionTypesCount: 13,
logo: (
<div className="h-14 w-14 rounded-full bg-[#FFB800] flex items-center justify-center relative overflow-hidden">
{/* Simple Duolingo-like representation if image not available */}
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent" />
<div className="h-8 w-8 bg-white rounded-full flex items-center justify-center">
<div className="h-4 w-4 bg-[#FFB800] rounded-sm transform rotate-45" />
</div>
</div>
),
buttonText: "Manage Detail",
},
{
id: "ielts",
name: "IELTS Academic",
description:
"Full preparation for IELTS speaking, writing, listening, and reading.",
coursesCount: 4,
questionTypesCount: 18,
logo: (
<div className="flex items-center gap-1">
<span className="text-[28px] font-black tracking-tighter text-[#E11D48] ">
IELTS
</span>
<span className="text-[8px] font-bold text-[#E11D48] mt-2 tracking-widest uppercase">
</span>
</div>
),
buttonText: "View Detail",
},
],
courses: [],
},
"skill-based": {
title: "Skill-Based Courses",
@ -84,6 +88,327 @@ export function ProgramDetailPage() {
const currentProgram =
programs[programType || "proficiency"] || programs.proficiency;
const loadCatalogCourses = useCallback(async () => {
if (programType !== "proficiency") return;
setCatalogLoading(true);
try {
const response = await getExamPrepCatalogCourses({ limit: 20, offset: 0 });
const rows = response.data?.data?.catalog_courses;
const list = Array.isArray(rows) ? rows : [];
setCreatedCourses(
list.map((row) => ({
id: Number(row.id),
name: row.name?.trim() || `Course ${row.id}`,
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0),
modulesCount: Number(row.modules_count ?? 0),
lessonsCount: Number(row.lessons_count ?? 0),
})),
);
} catch (error) {
console.error(error);
toast.error("Failed to fetch catalog courses");
setCreatedCourses([]);
} finally {
setCatalogLoading(false);
}
}, [programType]);
useEffect(() => {
void loadCatalogCourses();
}, [loadCatalogCourses]);
const proficiencyCourses = [
...currentProgram.courses,
...createdCourses.map((course) => ({
id: course.id,
name: course.name,
description: course.description,
units_count: course.unitsCount,
modules_count: course.modulesCount,
lessons_count: course.lessonsCount,
logo: null,
thumbnail: course.thumbnail ?? "",
sort_order: course.sortOrder,
buttonText: "View Detail",
})),
];
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
const isMinioUrl = (value: string) => {
try {
const url = new URL(value);
return url.host === "s3.yimaruacademy.com";
} catch {
return false;
}
};
const autoUploadThumbnailUrlIfNeeded = async (rawValue: string) => {
const candidate = rawValue.trim();
if (!candidate) return;
if (!isHttpUrl(candidate)) return;
if (isMinioUrl(candidate)) return;
if (uploadingThumbnail || creating) return;
setUploadingThumbnail(true);
try {
const uploaded = await uploadImageFile(candidate);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) {
throw new Error("Failed to upload thumbnail URL to MinIO");
}
setCreateThumbnail(uploadedUrl);
setCreateThumbnailFromUpload(true);
toast.success("Thumbnail URL uploaded to MinIO");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail URL";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
const trimmed = rawValue.trim();
if (!trimmed) return "";
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
const uploaded = await uploadImageFile(trimmed);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) {
throw new Error("Failed to upload thumbnail URL to MinIO");
}
return uploadedUrl;
};
const handleCreateCourse = async () => {
if (programType !== "proficiency") {
toast.error("Create Course is supported only for proficiency catalog.");
return;
}
const name = createName.trim();
if (!name) {
toast.error("Course name is required");
return;
}
setCreating(true);
try {
let thumbnailToSend: string | null = createThumbnail.trim() || null;
if (
thumbnailToSend &&
!createThumbnailFromUpload &&
isHttpUrl(thumbnailToSend) &&
!isMinioUrl(thumbnailToSend)
) {
const uploaded = await uploadImageFile(thumbnailToSend);
const uploadedUrl = uploaded.data?.data?.url?.trim();
if (!uploadedUrl) {
throw new Error("Failed to upload thumbnail URL to MinIO");
}
thumbnailToSend = uploadedUrl;
}
const response = await createExamPrepCatalogCourse({
name,
description: createDescription.trim() || null,
thumbnail: thumbnailToSend,
});
const row = response.data?.data;
if (!row?.id) {
throw new Error("Missing created course payload");
}
setCreatedCourses((prev) => [
{
id: row.id,
name: row.name ?? name,
description: row.description?.trim() || createDescription.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0),
modulesCount: Number(row.modules_count ?? 0),
lessonsCount: Number(row.lessons_count ?? 0),
},
...prev,
]);
await loadCatalogCourses();
toast.success("Course created");
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
setCreateThumbnailFromUpload(false);
setCreateOpen(false);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to create course";
toast.error(message);
} finally {
setCreating(false);
}
};
const openEditCourse = (course: (typeof proficiencyCourses)[number]) => {
const idNum = Number(course.id);
if (!Number.isFinite(idNum)) return;
setEditingCourseId(idNum);
setEditName(String(course.name ?? ""));
setEditDescription(String(course.description ?? ""));
setEditThumbnail(String(course.thumbnail ?? ""));
setEditSortOrder(String(course.sort_order ?? 1));
};
const closeEditCourse = () => {
if (savingEdit || uploadingEditThumbnail) return;
setEditingCourseId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
};
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;
}
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 (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingEditThumbnail(false);
}
};
const handleSaveEditCourse = async () => {
if (!editingCourseId) return;
const name = editName.trim();
if (!name) {
toast.error("Course name is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
return;
}
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
const response = await updateExamPrepCatalogCourse(editingCourseId, {
name,
description: editDescription.trim() || null,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
});
const row = response.data?.data;
setCreatedCourses((prev) =>
prev.map((course) =>
course.id === editingCourseId
? {
...course,
name: row?.name ?? name,
description: row?.description?.trim() || editDescription.trim() || "—",
thumbnail: row?.thumbnail?.trim() || null,
sortOrder: Number(row?.sort_order ?? sortOrderNum),
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
modulesCount: Number(row?.modules_count ?? course.modulesCount ?? 0),
lessonsCount: Number(row?.lessons_count ?? course.lessonsCount ?? 0),
}
: course,
),
);
await loadCatalogCourses();
toast.success("Course updated");
closeEditCourse();
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update course";
toast.error(message);
} finally {
setSavingEdit(false);
}
};
const handleDeleteCourse = async () => {
if (!deletingCourseId) return;
setDeletingCourse(true);
try {
await deleteExamPrepCatalogCourse(deletingCourseId);
await loadCatalogCourses();
toast.success("Course deleted");
setDeletingCourseId(null);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to delete course";
toast.error(message);
} finally {
setDeletingCourse(false);
}
};
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;
}
setUploadingThumbnail(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);
setCreateThumbnailFromUpload(true);
toast.success("Thumbnail uploaded");
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to upload thumbnail";
toast.error(message);
} finally {
setUploadingThumbnail(false);
}
};
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
@ -107,84 +432,136 @@ export function ProgramDetailPage() {
</div>
<div className="flex items-center gap-3 pt-2">
<Dialog>
<Dialog
open={createOpen}
onOpenChange={(open) => {
if (!open && (creating || uploadingThumbnail)) return;
setCreateOpen(open);
}}
>
<DialogTrigger asChild>
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2">
<Plus className="h-5 w-5" />
Create Course
</Button>
</DialogTrigger>
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
<div className="bg-white">
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Create Course
</DialogTitle>
</DialogHeader>
<div className="p-8 space-y-8">
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Name
</label>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="e.g. TOEFL, IELTS"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Course Order
Description
</label>
<Select defaultValue="1">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</Select>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating}
/>
</div>
{/* Thumbnail Field */}
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Thumbnail
</label>
<div className="relative group cursor-pointer">
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
<input
ref={createThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
disabled={creating || uploadingThumbnail}
/>
<button
type="button"
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
disabled={creating || uploadingThumbnail}
onClick={() => createThumbnailFileInputRef.current?.click()}
>
<div className="flex flex-col items-center justify-center">
<div className="mb-4">
<img
src={uploadIcon}
alt="Upload icon"
className="h-10 w-10"
/>
<img src={uploadIcon} alt="" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="text-brand-500 font-bold hover:underline">
Click to upload
<span className="font-bold text-brand-500">
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">
or drag and drop
</span>
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
JPG, PNG (MAX 1 MB)
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
JPG, PNG (MAX 5 MB)
</p>
</div>
</div>
</button>
{createThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={createThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={createThumbnail}
onChange={(e) => {
setCreateThumbnail(e.target.value);
setCreateThumbnailFromUpload(false);
}}
onBlur={(e) => {
void autoUploadThumbnailUrlIfNeeded(e.target.value);
}}
onPaste={(e) => {
const pasted = e.clipboardData.getData("text");
if (!pasted) return;
setCreateThumbnail(pasted);
setCreateThumbnailFromUpload(false);
void autoUploadThumbnailUrlIfNeeded(pasted);
}}
placeholder="Optional thumbnail URL (or leave empty for null)"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating || uploadingThumbnail}
/>
</div>
</div>
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
disabled={creating || uploadingThumbnail}
>
Cancel
</Button>
</DialogClose>
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
Create Program
<Button
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creating || uploadingThumbnail}
onClick={() => void handleCreateCourse()}
>
{creating ? "Creating..." : "Create Course"}
</Button>
</div>
</div>
@ -221,13 +598,70 @@ export function ProgramDetailPage() {
{/* Cards Grid */}
<div className="flex flex-wrap gap-8 mt-10">
{currentProgram.courses.map((course: any) => (
<Card
key={course.id}
className="bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
>
{programType === "proficiency" && catalogLoading ? (
<p className="text-sm text-grayScale-500">Loading catalog courses...</p>
) : null}
{(programType === "proficiency"
? proficiencyCourses
: currentProgram.courses
).length === 0 && !catalogLoading ? (
<div className="w-full 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 catalog courses yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first exam-prep catalog course to start organizing units, modules, and lessons.
</p>
</div>
) : (
(programType === "proficiency"
? proficiencyCourses
: currentProgram.courses
).map((course: any) => (
<Card
key={course.id}
className="group relative bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
>
{programType === "proficiency" ? (
<div className="absolute right-3 top-3 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"
onClick={() => openEditCourse(course)}
aria-label={`Edit ${course.name}`}
>
<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"
onClick={() => setDeletingCourseId(Number(course.id))}
aria-label={`Delete ${course.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
{/* Logo */}
<div className="h-16 flex items-center">{course.logo}</div>
<div className="h-16 flex items-center">
{course.thumbnail ? (
<ResolvedImage
src={course.thumbnail}
alt={course.name}
className="h-14 w-14 rounded-full object-cover"
/>
) : course.logo ? (
course.logo
) : (
<div className="h-14 w-14 rounded-full bg-brand-50 text-brand-600 grid place-items-center text-xs font-bold">
{String(course.name ?? "C").slice(0, 2).toUpperCase()}
</div>
)}
</div>
{/* Content */}
<div className="space-y-4 pt-2 flex-1">
@ -244,13 +678,19 @@ export function ProgramDetailPage() {
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
<ClipboardList className="h-3 w-3 text-grayScale-400" />
<span className="text-[12px] ">
{course.coursesCount} Courses
{Number(course.units_count ?? 0)} Units
</span>
</div>
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
<ListChecks className="h-3 w-3 text-grayScale-400" />
<span className="text-[12px] ">
{course.questionTypesCount} Question Types
{Number(course.modules_count ?? 0)} Modules
</span>
</div>
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
<ListChecks className="h-3 w-3 text-grayScale-400" />
<span className="text-[12px] ">
{Number(course.lessons_count ?? 0)} Lessons
</span>
</div>
</div>
@ -265,9 +705,166 @@ export function ProgramDetailPage() {
{course.buttonText}
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
</Button>
</Card>
))}
</Card>
))
)}
</div>
<Dialog
open={editingCourseId !== null}
onOpenChange={(open) => {
if (!open && (savingEdit || uploadingEditThumbnail)) return;
if (!open) closeEditCourse();
}}
>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
Edit Course
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Name</label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
ref={editThumbnailFileInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void handleEditThumbnailFile(e)}
disabled={savingEdit || uploadingEditThumbnail}
/>
<button
type="button"
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => editThumbnailFileInputRef.current?.click()}
disabled={savingEdit || uploadingEditThumbnail}
>
<div className="flex flex-col items-center justify-center">
<div className="mb-4">
<img src={uploadIcon} alt="" className="h-10 w-10" />
</div>
<p className="text-[15px]">
<span className="font-bold text-brand-500">
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
JPG, PNG (MAX 5 MB)
</p>
</div>
</button>
{editThumbnail.trim() ? (
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
<ResolvedImage
src={editThumbnail.trim()}
alt=""
className="h-28 w-full object-cover"
/>
</div>
) : null}
<Input
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
placeholder="https://..."
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
onClick={closeEditCourse}
disabled={savingEdit || uploadingEditThumbnail}
>
Cancel
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
onClick={() => void handleSaveEditCourse()}
disabled={savingEdit || uploadingEditThumbnail}
>
{savingEdit ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deletingCourseId !== null}
onOpenChange={(open) => {
if (!open && !deletingCourse) setDeletingCourseId(null);
}}
>
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
<div className="bg-white">
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Delete Course
</DialogTitle>
</DialogHeader>
<div className="px-6 py-6 text-sm text-grayScale-600">
Are you sure you want to delete this course? This action cannot be undone.
</div>
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setDeletingCourseId(null)}
disabled={deletingCourse}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={() => void handleDeleteCourse()}
disabled={deletingCourse}
>
{deletingCourse ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Play, Pause, X } from "lucide-react";
import { cn } from "../../../../lib/utils";
import { resolveDisplayMediaUrl } from "../../../../lib/mediaUrl";
interface VoicePromptProps {
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
@ -21,13 +22,34 @@ export function VoicePrompt({
const [bars, setBars] = useState<number[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); // 01
const [playableSrc, setPlayableSrc] = useState("");
const audioRef = useRef<HTMLAudioElement | null>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const raw = src?.trim() || "";
if (!raw) {
setPlayableSrc("");
return;
}
try {
const resolved = await resolveDisplayMediaUrl(raw);
if (!cancelled) setPlayableSrc(resolved || raw);
} catch {
if (!cancelled) setPlayableSrc(raw);
}
})();
return () => {
cancelled = true;
};
}, [src]);
// ─── Decode audio and build waveform bars ───────────────────────────────────
useEffect(() => {
if (!src) {
if (!playableSrc) {
// No real audio — generate plausible static bars
setBars(generateFakeBars());
return;
@ -36,7 +58,7 @@ export function VoicePrompt({
let cancelled = false;
const audioCtx = new AudioContext();
fetch(src)
fetch(playableSrc)
.then((r) => r.arrayBuffer())
.then((buf) => audioCtx.decodeAudioData(buf))
.then((decoded) => {
@ -62,7 +84,15 @@ export function VoicePrompt({
return () => {
cancelled = true;
};
}, [src]);
}, [playableSrc]);
useEffect(() => {
audioRef.current?.pause();
audioRef.current = null;
setIsPlaying(false);
setProgress(0);
stopProgressLoop();
}, [playableSrc]);
// ─── Sync progress while playing ────────────────────────────────────────────
const startProgressLoop = () => {
@ -84,10 +114,10 @@ export function VoicePrompt({
// ─── Play / Pause ────────────────────────────────────────────────────────────
const handlePlayPause = () => {
if (!src) return;
if (!playableSrc) return;
if (!audioRef.current) {
audioRef.current = new Audio(src);
audioRef.current = new Audio(playableSrc);
audioRef.current.onended = () => {
setIsPlaying(false);
setProgress(0);

View File

@ -143,6 +143,234 @@ export interface CreateProgramCourseResponse {
metadata: unknown | null
}
/** Exam prep catalog course row (e.g. IELTS / DET cards) */
export interface ExamPrepCatalogCourseItem {
id: number
name: string
description?: string | null
thumbnail?: string | null
sort_order?: number
units_count?: number
modules_count?: number
lessons_count?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepCatalogCourseRequest {
name: string
description?: string | null
thumbnail?: string | null
}
export interface CreateExamPrepCatalogCourseResponse {
message: string
data: ExamPrepCatalogCourseItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepCatalogCoursesResponse {
message: string
data: {
offset: number
limit: number
total_count: number
catalog_courses: ExamPrepCatalogCourseItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepCatalogCourseRequest {
name: string
description?: string | null
thumbnail?: string | null
sort_order: number
}
export interface UpdateExamPrepCatalogCourseResponse {
message: string
data: ExamPrepCatalogCourseItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface ExamPrepCatalogUnitItem {
id: number
catalog_course_id: number
name: string
description?: string | null
thumbnail?: string | null
sort_order?: number
modules_count?: number
lessons_count?: number
videos_count?: number
practices_count?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepCatalogUnitRequest {
name: string
description?: string | null
thumbnail?: string | null
}
export interface CreateExamPrepCatalogUnitResponse {
message: string
data: ExamPrepCatalogUnitItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepCatalogUnitRequest {
name: string
description?: string | null
thumbnail?: string | null
sort_order: number
}
export interface UpdateExamPrepCatalogUnitResponse {
message: string
data: ExamPrepCatalogUnitItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepCatalogUnitsResponse {
message: string
data: {
offset: number
limit: number
total_count: number
units: ExamPrepCatalogUnitItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface ExamPrepUnitModuleItem {
id: number
unit_id: number
name: string
description?: string | null
thumbnail?: string | null
icon?: string | null
sort_order?: number
lessons_count?: number
videos_count?: number
practices_count?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepUnitModuleRequest {
name: string
description?: string | null
thumbnail?: string | null
icon?: string | null
}
export interface CreateExamPrepUnitModuleResponse {
message: string
data: ExamPrepUnitModuleItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepUnitModuleRequest {
name: string
description?: string | null
thumbnail?: string | null
icon?: string | null
sort_order: number
}
export interface UpdateExamPrepUnitModuleResponse {
message: string
data: ExamPrepUnitModuleItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepUnitModulesResponse {
message: string
data: {
offset: number
limit: number
total_count: number
modules: ExamPrepUnitModuleItem[]
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface ExamPrepModuleLessonItem {
id: number
unit_module_id: number
title: string
video_url: string
thumbnail?: string | null
description?: string | null
sort_order?: number
created_at?: string
updated_at?: string
}
export interface CreateExamPrepModuleLessonRequest {
title: string
video_url: string
thumbnail?: string | null
description?: string | null
}
export interface CreateExamPrepModuleLessonResponse {
message: string
data: ExamPrepModuleLessonItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdateExamPrepModuleLessonRequest {
title: string
video_url?: string | null
thumbnail?: string | null
description?: string | null
sort_order: number
}
export interface UpdateExamPrepModuleLessonResponse {
message: string
data: ExamPrepModuleLessonItem
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetExamPrepModuleLessonsResponse {
message: string
data: {
lessons: ExamPrepModuleLessonItem[]
total_count: number
limit: number
offset: number
}
success: boolean
status_code: number
metadata: unknown | null
}
export interface GetProgramCoursesResponse {
message: string
data: {