import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react" import { useLocation, useNavigate } from "react-router-dom" import { ChevronDown, ChevronRight, ClipboardList, GripVertical, HelpCircle, Image as ImageIcon, Languages, Lightbulb, Link2, Mic, Plus, Search, Trash2, Video, } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { addQuestionToSet, createPractice, createQuestion, createCourse, createCourseCategory, createHumanLanguageLesson, createModuleInLevel, createSubModuleInModule, deleteModule, deleteCourse, deleteCourseSubCategory, deleteQuestionSet, deleteQuestion, deleteSubModule, getHumanLanguageHierarchy, getQuestionById, getPracticeQuestions, getPracticeQuestionsByPractice, getQuestionSetById, updateQuestionSet, updateQuestion, } from "../../api/courses.api" import { Badge } from "../../components/ui/badge" import type { CreateQuestionRequest, HumanLanguageCourseTree, HumanLanguageSubCategoryTree, LearningPathPractice, QuestionDetail, QuestionSetQuestion, } from "../../types/course.types" import { cn } from "../../lib/utils" import { toast } from "sonner" import { Input } from "../../components/ui/input" import { uploadVideoFile } from "../../api/files.api" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { createEmptyPracticeQuestionDraft, PracticeQuestionEditorFields, type PracticeQuestionEditorValue, } from "../../components/content-management/PracticeQuestionEditorFields" const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const const HUMAN_LANGUAGE_SCROLL_KEY = "human-language-page:scroll-y" type SubModulePanelTab = "lessons" | "practices" type SubModuleCardSelection = { lessonId: number | null; practiceId: number | null } type PracticeQuestionsFetchState = | { status: "idle" } | { status: "loading"; startedAt: number } | { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number } | { status: "error"; message: string } type PracticeDialogState = | { open: false } | { open: true mode: "create" | "edit" subModuleId: number practiceId?: number } type LessonDialogState = | { open: false } | { open: true lessonId: number questionSetId: number } type LessonListItem = { id: number question_set_id: number title: string display_order: number status: string question_count: number intro_video_url: string } type QuestionDialogState = | { open: false } | { open: true mode: "create" | "edit" practiceId: number questionId?: number } function practiceStatusStyle(status: string): string { const u = status.toUpperCase() if (u === "PUBLISHED") return "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200" if (u === "DRAFT") return "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200" if (u === "ARCHIVED") return "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200" return "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200" } function questionTypeBadgeClass(questionType: string): string { const t = questionType.toUpperCase().replace(/\s+/g, "_") if (t === "MCQ" || t.includes("MULTIPLE")) { return "border-transparent bg-violet-50 text-violet-800 ring-1 ring-inset ring-violet-200" } if (t === "TRUE_FALSE" || t.includes("TRUE")) { return "border-transparent bg-sky-50 text-sky-800 ring-1 ring-inset ring-sky-200" } if (t === "SHORT" || t === "SHORT_ANSWER") { return "border-transparent bg-emerald-50 text-emerald-800 ring-1 ring-inset ring-emerald-200" } if (t === "AUDIO") { return "border-transparent bg-orange-50 text-orange-800 ring-1 ring-inset ring-orange-200" } return "border-transparent bg-grayScale-100 text-grayScale-700 ring-1 ring-inset ring-grayScale-200" } function formatQuestionTypeLabel(raw: string): string { return String(raw ?? "—") .replace(/_/g, " ") .trim() .toLowerCase() .replace(/\b\w/g, (c) => c.toUpperCase()) } const URL_REGEX = /(https?:\/\/[^\s<>"')\]]+)/gi function extractUrls(text: string): string[] { const out = text.match(URL_REGEX) ?? [] return [...new Set(out)] } function normalizeUrl(raw: string): string { return raw.trim().replace(/[),.;!?]+$/, "") } function getVimeoEmbedUrl(url: string): string | null { const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i) return m?.[1] ? `https://player.vimeo.com/video/${m[1]}` : null } function detectMediaType(url: string, hint?: "audio" | "video" | "image"): "audio" | "video" | "image" | "unknown" { if (hint) return hint const vimeo = getVimeoEmbedUrl(url) if (vimeo) return "video" const clean = url.split("?")[0].toLowerCase() if (/\.(png|jpe?g|gif|webp|svg|avif|bmp)$/.test(clean)) return "image" if (/\.(mp4|webm|ogg|mov|m4v)$/.test(clean)) return "video" if (/\.(mp3|wav|m4a|aac|ogg|webm)$/.test(clean)) return "audio" return "unknown" } function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("Request timed out")), ms) promise .then((value) => { clearTimeout(timer) resolve(value) }) .catch((err) => { clearTimeout(timer) reject(err) }) }) } type CefrLevel = (typeof CEFR_LEVELS)[number] type PendingRemove = { ids: number[] target: "sub_module" | "module" key: string successMessage: string title: string description: string } function MediaPreviewCard({ urlRaw, hint, className = "mt-2", label, }: { urlRaw: string hint?: "audio" | "video" | "image" className?: string label?: string }) { const normalized = normalizeUrl(urlRaw) const [resolvedUrl, setResolvedUrl] = useState(normalized) const [resolving, setResolving] = useState(false) useEffect(() => { let cancelled = false const run = async () => { if (!normalized) { setResolvedUrl("") return } if (/^https?:\/\//i.test(normalized)) { setResolvedUrl(normalized) return } setResolving(true) try { const url = await resolveMediaPreviewUrl(normalized) if (!cancelled) setResolvedUrl(url || normalized) } catch { if (!cancelled) setResolvedUrl(normalized) } finally { if (!cancelled) setResolving(false) } } void run() return () => { cancelled = true } }, [normalized]) if (!normalized) return null const previewUrl = resolvedUrl || normalized const mediaType = detectMediaType(previewUrl, hint) const vimeoEmbed = getVimeoEmbedUrl(previewUrl) const showPlayer = mediaType === "image" || mediaType === "video" || mediaType === "audio" return (
{label ? (

{hint === "image" ? ( ) : hint === "audio" ? ( ) : hint === "video" ? (

) : null} {resolving ? (
Resolving media URL...
) : mediaType === "image" ? ( ) : mediaType === "video" ? ( vimeoEmbed ? (