import { useEffect, useMemo, useState } from "react" import { Link } from "react-router-dom" import { ChevronDown, ChevronRight, ClipboardList, HelpCircle, Image as ImageIcon, Languages, Lightbulb, Link2, Loader2, 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, deletePractice, deleteQuestion, deleteSubCourse, getHumanLanguageHierarchy, getQuestionById, getPracticeQuestions, getPracticeQuestionsByPractice, updatePractice, updateQuestion, } from "../../api/courses.api" import { Badge } from "../../components/ui/badge" import type { CreateQuestionRequest, HumanLanguageCourseTree, HumanLanguageSubCategoryTree, LearningPathPractice, LearningPathVideo, QuestionDetail, QuestionSetQuestion, } from "../../types/course.types" import { cn } from "../../lib/utils" import { toast } from "sonner" const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const 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 QuestionDialogState = | { open: false } | { open: true mode: "create" | "edit" practiceId: number questionId?: number } function formatDurationSeconds(total: number): string { const s = Math.max(0, Math.floor(total)) const m = Math.floor(s / 60) const r = s % 60 return `${m}:${r.toString().padStart(2, "0")}` } 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[] key: string successMessage: string title: string description: string } export function HumanLanguagePage() { const [loading, setLoading] = useState(false) const [categoryId, setCategoryId] = useState(null) const [subCategories, setSubCategories] = useState([]) const [selectedSubCategoryId, setSelectedSubCategoryId] = useState("ALL") const [selectedCourseId, setSelectedCourseId] = useState("ALL") const [selectedLevel, setSelectedLevel] = useState("ALL") const [collapsedLevels, setCollapsedLevels] = useState([]) const [creatingKey, setCreatingKey] = useState(null) const [quickSubCategoryName, setQuickSubCategoryName] = useState("") const [quickCourseName, setQuickCourseName] = useState("") const [quickSearch, setQuickSearch] = useState("") const [quickCreating, setQuickCreating] = useState(false) const [deletingKey, setDeletingKey] = useState(null) /** Course IDs whose path body is collapsed (headers stay visible). */ const [collapsedPathIds, setCollapsedPathIds] = useState([]) const [pendingRemove, setPendingRemove] = useState(null) /** Per sub-module panel tab (lessons vs practices). */ const [subModulePanelTab, setSubModulePanelTab] = useState>({}) /** Selected lesson / practice card per sub-module (for inline detail panel). */ const [subModuleCardSelection, setSubModuleCardSelection] = useState>({}) const [practiceQuestionsState, setPracticeQuestionsState] = useState>({}) const [practiceDialog, setPracticeDialog] = useState({ open: false }) const [questionDialog, setQuestionDialog] = useState({ open: false }) const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" }) const [questionForm, setQuestionForm] = useState({ questionText: "", questionType: "MCQ" as "MCQ" | "TRUE_FALSE" | "SHORT", difficulty: "EASY", points: 1, tips: "", explanation: "", imageUrl: "", voicePrompt: "", sampleAnswerVoicePrompt: "", audioCorrectAnswerText: "", optionA: "", optionB: "", optionC: "", optionD: "", correctOption: "A" as "A" | "B" | "C" | "D", shortAnswer: "", }) const [questionDetailById, setQuestionDetailById] = useState>({}) const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null) const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null) const [savingPractice, setSavingPractice] = useState(false) const [savingQuestion, setSavingQuestion] = useState(false) const [deletingPractice, setDeletingPractice] = useState(false) const [deletingQuestion, setDeletingQuestion] = useState(false) /** While fetching full question detail before opening the edit dialog (avoids empty form flash). */ const [loadingQuestionEditId, setLoadingQuestionEditId] = useState(null) /** Show inline field errors only after an explicit save attempt (avoids noisy empty-state on open). */ const [practiceSubmitAttempted, setPracticeSubmitAttempted] = useState(false) const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false) const [practiceFormTouched, setPracticeFormTouched] = useState(false) const [questionFormTouched, setQuestionFormTouched] = useState(false) const renderMediaPreview = ( urlRaw: string, hint?: "audio" | "video" | "image", className = "mt-2", label?: string, ) => { const url = normalizeUrl(urlRaw) if (!url) return null const mediaType = detectMediaType(url, hint) const vimeoEmbed = getVimeoEmbedUrl(url) const showPlayer = mediaType === "image" || mediaType === "video" || mediaType === "audio" return (
{label ? (

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

) : null} {mediaType === "image" ? ( ) : mediaType === "video" ? ( vimeoEmbed ? (