import { useCallback, useEffect, useRef, useState } from "react"; import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import { Card, CardContent } from "../../components/ui/card"; import { Button } from "../../components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogFooter, } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Textarea } from "../../components/ui/textarea"; import uploadIcon from "../../assets/icons/upload.png"; import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"; import alertSrc from "../../assets/Alert.svg"; import { getLearningPrograms, createLearningProgram, updateLearningProgram, deleteLearningProgram, } from "../../api/courses.api"; import { refreshFileUrl, uploadImageFile } from "../../api/files.api"; import type { LearningProgramListItem } from "../../types/course.types"; /** Presigned MinIO/S3 URLs and our storage hosts — safe to send to POST /files/refresh-url. */ function looksLikeRefreshableFileUrl(url: string): boolean { const trimmed = url.trim(); if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return false; try { const u = new URL(trimmed); const q = u.search.toLowerCase(); if (q.includes("x-amz-")) return true; const h = u.hostname.toLowerCase(); if (h.includes("yimaruacademy.com")) return true; if (h.includes("minio")) return true; return false; } catch { return false; } } export function LearnEnglishPage() { const [programs, setPrograms] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [editingProgram, setEditingProgram] = useState(null); const [editName, setEditName] = useState(""); const [editDescription, setEditDescription] = useState(""); const [editSortOrder, setEditSortOrder] = useState(""); const [editThumbnail, setEditThumbnail] = useState(""); const [savingEdit, setSavingEdit] = useState(false); const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false); const editThumbnailFileInputRef = useRef(null); const [createOpen, setCreateOpen] = useState(false); const [createName, setCreateName] = useState(""); const [createDescription, setCreateDescription] = useState(""); const [createSortOrder, setCreateSortOrder] = useState(""); const [createThumbnail, setCreateThumbnail] = useState(""); const [createSaving, setCreateSaving] = useState(false); const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false); const createThumbnailFileInputRef = useRef(null); const [deletingProgram, setDeletingProgram] = useState(null); const [deleting, setDeleting] = useState(false); const openEdit = (program: LearningProgramListItem) => { setEditingProgram(program); setEditName(program.name ?? ""); setEditDescription(program.description?.trim() ?? ""); setEditSortOrder(String(program.sort_order ?? 0)); setEditThumbnail(program.thumbnail?.trim() ?? ""); }; const closeEdit = () => { setEditingProgram(null); setEditName(""); setEditDescription(""); setEditSortOrder(""); setEditThumbnail(""); setUploadingEditThumbnail(false); if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = ""; }; const handleEditThumbnailFile = async ( event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (!file.type.startsWith("image/")) { toast.error("Please choose an image file"); return; } const maxBytes = 5 * 1024 * 1024; if (file.size > maxBytes) { toast.error("Image is too large", { description: "Maximum size is 5 MB." }); return; } setUploadingEditThumbnail(true); try { const res = await uploadImageFile(file); const url = res.data?.data?.url?.trim(); if (!url) { throw new Error("Upload did not return a file URL"); } setEditThumbnail(url); toast.success("Thumbnail uploaded"); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to upload thumbnail"; toast.error(msg); } finally { setUploadingEditThumbnail(false); } }; const clearCreateFormFields = () => { setCreateName(""); setCreateDescription(""); setCreateSortOrder(""); setCreateThumbnail(""); if (createThumbnailFileInputRef.current) { createThumbnailFileInputRef.current.value = ""; } }; const handleCreateDialogOpenChange = (open: boolean) => { if (!open && (createSaving || createUploadingThumbnail)) return; clearCreateFormFields(); setCreateOpen(open); }; const handleCreateThumbnailFile = async ( event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (!file.type.startsWith("image/")) { toast.error("Please choose an image file"); return; } const maxBytes = 5 * 1024 * 1024; if (file.size > maxBytes) { toast.error("Image is too large", { description: "Maximum size is 5 MB." }); return; } setCreateUploadingThumbnail(true); try { const res = await uploadImageFile(file); const url = res.data?.data?.url?.trim(); if (!url) { throw new Error("Upload did not return a file URL"); } setCreateThumbnail(url); toast.success("Thumbnail uploaded"); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to upload thumbnail"; toast.error(msg); } finally { setCreateUploadingThumbnail(false); } }; const handleCreateProgram = async () => { const name = createName.trim(); if (!name) { toast.error("Program name is required"); return; } const sortOrderRaw = createSortOrder.trim(); if (!sortOrderRaw) { toast.error("Sort order is required"); return; } const sort_order = Number(sortOrderRaw); if (!Number.isInteger(sort_order) || sort_order < 0) { toast.error("Sort order must be a whole number of 0 or greater"); return; } setCreateSaving(true); try { await createLearningProgram({ name, description: createDescription.trim(), thumbnail: createThumbnail.trim(), sort_order, }); toast.success("Program created"); clearCreateFormFields(); setCreateOpen(false); await fetchPrograms(); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to create program"; toast.error(msg); } finally { setCreateSaving(false); } }; const handleSaveEdit = async () => { if (!editingProgram) return; const name = editName.trim(); if (!name) { toast.error("Program name is required"); return; } const sortOrderRaw = editSortOrder.trim(); if (!sortOrderRaw) { toast.error("Sort order is required"); return; } const sort_order = Number(sortOrderRaw); if (!Number.isInteger(sort_order) || sort_order < 0) { toast.error("Sort order must be a whole number of 0 or greater"); return; } setSavingEdit(true); try { await updateLearningProgram(editingProgram.id, { name, description: editDescription.trim(), thumbnail: editThumbnail.trim(), sort_order, }); toast.success("Program updated"); closeEdit(); await fetchPrograms(); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to update program"; toast.error(msg); } finally { setSavingEdit(false); } }; const handleConfirmDelete = async () => { if (!deletingProgram) return; setDeleting(true); try { await deleteLearningProgram(deletingProgram.id); toast.success("Program deleted"); setDeletingProgram(null); await fetchPrograms(); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? "Failed to delete program"; toast.error(msg); } finally { setDeleting(false); } }; const fetchPrograms = useCallback(async () => { setLoading(true); setError(null); try { const res = await getLearningPrograms({ limit: 100, offset: 0 }); const raw = res.data?.data?.programs; const list = Array.isArray(raw) ? raw : []; const sorted = [...list].sort( (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0), ); setPrograms(sorted); void (async () => { const results = await Promise.all( sorted.map(async (p) => { const ref = p.thumbnail?.trim(); if (!ref || !looksLikeRefreshableFileUrl(ref)) return null; try { const res = await refreshFileUrl(ref); const url = res.data?.data?.url?.trim(); if (!url) return null; return { id: p.id, url }; } catch { return null; } }), ); const map = new Map( results .filter((r): r is { id: number; url: string } => r != null) .map((r) => [r.id, r.url] as const), ); if (map.size === 0) return; setPrograms((prev) => prev.map((prog) => { const next = map.get(prog.id); return next ? { ...prog, thumbnail: next } : prog; }), ); })(); } catch (e) { console.error(e); setError("Failed to load programs"); setPrograms([]); toast.error("Could not load programs", { description: "Check your connection or try again.", }); } finally { setLoading(false); } }, []); useEffect(() => { void fetchPrograms(); }, [fetchPrograms]); return (
{/* Header section */}

Learn English

Manage learning content by program — cards load from the server

Add New Program Create a new learning program. Add a thumbnail as an image URL or by uploading a file. {/* Gradient Divider */}
{ e.preventDefault(); void handleCreateProgram(); }} >
setCreateName(e.target.value)} placeholder="e.g. Intermediate Track" className="h-12 rounded-xl ring-0" disabled={createSaving || createUploadingThumbnail} />