import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Mic, Plus, Trash2, Upload } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" import { Textarea } from "../../components/ui/textarea" import { Stepper } from "../../components/ui/stepper" import { addQuestionToSet, deleteQuestion, getCourseCategories, getCoursesByCategory, getQuestionById, getSubModulesByCourse, createQuestion, createQuestionSet, // getQuestions, getPracticeQuestionsByPractice, getQuestionSets, updateQuestion, } from "../../api/courses.api" import { resolveFileUrl, uploadAudioFile, uploadImageFile, uploadVideoFile } from "../../api/files.api" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "../../components/ui/dropdown-menu" import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types" import { toast } from "sonner" const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024 const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"]) const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024 const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"]) const SPEAKING_STEPS = ["Practice", "Questions", "Review"] type SubCourseOption = { id: number title: string courseTitle: string categoryName: string } type RecordingField = "voice_prompt" | "sample_answer_voice_prompt" type AudioQuestionDraft = { questionText: string difficulty: "EASY" | "MEDIUM" | "HARD" points: number voicePrompt: string sampleAnswerVoicePrompt: string audioCorrectAnswerText: string imageUrl: string tips: string explanation: string voicePromptPreviewUrl: string samplePromptPreviewUrl: string imagePreviewUrl: string uploadingVoicePrompt: boolean uploadingSamplePrompt: boolean uploadingImage: boolean recordingVoicePrompt: boolean recordingSamplePrompt: boolean } type PracticeFilterOption = { id: number title: string description?: string persona?: string status?: string } type AudioListQuestion = QuestionDetail & { practice_id: number | null practice_title: string | null } const createEmptyDraft = (): AudioQuestionDraft => ({ questionText: "", difficulty: "EASY", points: 1, voicePrompt: "", sampleAnswerVoicePrompt: "", audioCorrectAnswerText: "", imageUrl: "", tips: "", explanation: "", voicePromptPreviewUrl: "", samplePromptPreviewUrl: "", imagePreviewUrl: "", uploadingVoicePrompt: false, uploadingSamplePrompt: false, uploadingImage: false, recordingVoicePrompt: false, recordingSamplePrompt: false, }) function normalizeObjectKey(value: string) { const trimmed = value.trim() if (!trimmed) return "" if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed const protocolMatch = trimmed.match(/^[a-z]+:\/\//i) if (protocolMatch) { return trimmed.replace(/^[a-z]+:\/\//i, "") } return trimmed } /** Prefer direct storage URL; for Vimeo pipeline match SubCourseContentPage player URL shape. */ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null { if (!data) return null const pageUrl = data.url?.trim() const embedUrl = data.embed_url?.trim() if (embedUrl) { const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl } return pageUrl || null } function toVimeoEmbedUrl(rawUrl: string): string | null { try { const parsed = new URL(rawUrl.trim()) const host = parsed.hostname.toLowerCase() if (!host.includes("vimeo.com")) return null if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) { return parsed.toString() } 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") return hash ? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}` : `https://player.vimeo.com/video/${videoId}` } catch { return null } } export function SpeakingPage() { const [audioQuestions, setAudioQuestions] = useState([]) const [audioTotalCount, setAudioTotalCount] = useState(0) const [audioPage, setAudioPage] = useState(1) const [audioPageSize] = useState(12) const [practiceOptions, setPracticeOptions] = useState([]) const [selectedPracticeId, setSelectedPracticeId] = useState("") const [practiceFilterOpen, setPracticeFilterOpen] = useState(false) const [practiceFilterSearch, setPracticeFilterSearch] = useState("") const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState>({}) const [imagePreviewByQuestionId, setImagePreviewByQuestionId] = useState>({}) const [searchQuery, setSearchQuery] = useState("") const [selectedQuestionIds, setSelectedQuestionIds] = useState([]) const [bulkDeleting, setBulkDeleting] = useState(false) const [collapsedPracticeIds, setCollapsedPracticeIds] = useState([]) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [openCreate, setOpenCreate] = useState(false) const [setTitle, setSetTitle] = useState("") const [setDescription, setSetDescription] = useState("") const [introVideoUrl, setIntroVideoUrl] = useState("") const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false) const introVideoFileInputRef = useRef(null) const [subCourseId, setSubCourseId] = useState("") const [subCourseOptions, setSubCourseOptions] = useState([]) const [subCourseLoading, setSubCourseLoading] = useState(false) const [subCourseSearch, setSubCourseSearch] = useState("") const [subCourseMenuOpen, setSubCourseMenuOpen] = useState(false) const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("PUBLISHED") const [createdSetId, setCreatedSetId] = useState(null) const [creatingSet, setCreatingSet] = useState(false) const [currentStep, setCurrentStep] = useState(1) const [detailOpen, setDetailOpen] = useState(false) const [detailLoading, setDetailLoading] = useState(false) const [selectedQuestionDetail, setSelectedQuestionDetail] = useState(null) const [detailVoiceUrl, setDetailVoiceUrl] = useState("") const [detailSampleVoiceUrl, setDetailSampleVoiceUrl] = useState("") const [detailImageUrl, setDetailImageUrl] = useState("") const [detailEditing, setDetailEditing] = useState(false) const [detailSaving, setDetailSaving] = useState(false) const [detailDeleting, setDetailDeleting] = useState(false) const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState<{ id: number; text: string } | null>(null) const [detailForm, setDetailForm] = useState({ question_text: "", question_type: "AUDIO" as const, difficulty_level: "EASY" as "EASY" | "MEDIUM" | "HARD", points: 1, explanation: "", tips: "", voice_prompt: "", sample_answer_voice_prompt: "", image_url: "", status: "DRAFT" as "DRAFT" | "PUBLISHED" | "INACTIVE", audio_correct_answer_text: "", }) const [questionDrafts, setQuestionDrafts] = useState([createEmptyDraft()]) const [recordingModal, setRecordingModal] = useState<{ draftIndex: number field: RecordingField levels: number[] label: string elapsedSeconds: number isPaused: boolean } | null>(null) const activeRecordingRef = useRef<{ recorder: MediaRecorder stream: MediaStream chunks: BlobPart[] audioContext: AudioContext analyser: AnalyserNode rafId: number | null draftIndex: number field: RecordingField shouldUpload: boolean isPaused: boolean startedAtMs: number pausedTotalMs: number pauseStartedAtMs: number | null } | null>(null) const resolvePreviewUrl = useCallback(async (value: 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 fetchAudioQuestions = useCallback(async (page: number = audioPage) => { setLoading(true) try { const safePage = page < 1 ? 1 : page let rows: AudioListQuestion[] = [] let total = 0 if (selectedPracticeId) { const offset = (safePage - 1) * audioPageSize const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), { limit: audioPageSize, offset, question_type: "AUDIO", }) const practiceData = practiceRes.data?.data const selectedPractice = practiceOptions.find((p) => p.id === Number(selectedPracticeId)) rows = (practiceData?.questions ?? []).map((q) => ({ id: q.question_id || q.id, question_text: q.question_text, question_type: q.question_type, difficulty_level: q.difficulty_level ?? undefined, points: q.points ?? 0, explanation: q.explanation ?? undefined, tips: q.tips ?? undefined, voice_prompt: q.voice_prompt ?? undefined, sample_answer_voice_prompt: q.sample_answer_voice_prompt ?? undefined, image_url: q.image_url ?? undefined, status: q.question_status ?? "DRAFT", created_at: "", audio_correct_answer_text: q.audio_correct_answer_text ?? undefined, practice_id: Number(selectedPracticeId), practice_title: selectedPractice?.title ?? `Practice #${selectedPracticeId}`, })) const q = searchQuery.trim().toLowerCase() if (q) { rows = rows.filter((question) => { const haystack = `${question.question_text} ${question.audio_correct_answer_text ?? ""} ${question.practice_title ?? ""}`.toLowerCase() return haystack.includes(q) }) } total = searchQuery.trim() ? rows.length : (practiceData?.total_count ?? rows.length) } else { const groupedRows = await Promise.all( practiceOptions.map(async (practice) => { try { const res = await getPracticeQuestionsByPractice(practice.id, { limit: 100, offset: 0, question_type: "AUDIO", }) const questions = res.data?.data?.questions ?? [] return questions.map((q) => ({ id: q.question_id || q.id, question_text: q.question_text, question_type: q.question_type, difficulty_level: q.difficulty_level ?? undefined, points: q.points ?? 0, explanation: q.explanation ?? undefined, tips: q.tips ?? undefined, voice_prompt: q.voice_prompt ?? undefined, sample_answer_voice_prompt: q.sample_answer_voice_prompt ?? undefined, image_url: q.image_url ?? undefined, status: q.question_status ?? "DRAFT", created_at: "", audio_correct_answer_text: q.audio_correct_answer_text ?? undefined, practice_id: practice.id, practice_title: practice.title, })) } catch { return [] } }), ) rows = groupedRows.flat() const q = searchQuery.trim().toLowerCase() if (q) { rows = rows.filter((question) => { const haystack = `${question.question_text} ${question.audio_correct_answer_text ?? ""} ${question.practice_title ?? ""}`.toLowerCase() return haystack.includes(q) }) } total = rows.length const offset = (safePage - 1) * audioPageSize rows = rows.slice(offset, offset + audioPageSize) } setAudioQuestions(rows) setAudioTotalCount(total) setAudioPage(safePage) } catch (error) { console.error("Failed to fetch audio questions:", error) setAudioQuestions([]) setAudioTotalCount(0) } finally { setLoading(false) } }, [audioPage, audioPageSize, selectedPracticeId, practiceOptions, searchQuery]) useEffect(() => { fetchAudioQuestions() }, [fetchAudioQuestions, audioPageSize, selectedPracticeId]) const fetchPracticeOptions = useCallback(async () => { const batchSize = 100 let offset = 0 let total = Number.POSITIVE_INFINITY const all: QuestionSet[] = [] while (all.length < total) { const res = await getQuestionSets({ set_type: "PRACTICE", limit: batchSize, offset, }) const payload = res.data?.data let chunk: QuestionSet[] = [] let chunkTotal = 0 if (Array.isArray(payload)) { chunk = payload chunkTotal = payload.length } else if (payload && typeof payload === "object") { chunk = payload.question_sets ?? [] chunkTotal = payload.total_count ?? chunk.length } all.push(...chunk) total = chunkTotal if (chunk.length < batchSize) break offset += chunk.length } // Speaking page should only offer practices that already contain AUDIO questions. const checks = await Promise.all( all.map(async (practice) => { try { const res = await getPracticeQuestionsByPractice(practice.id, { limit: 20, offset: 0, question_type: "AUDIO", }) const questions = res.data?.data?.questions ?? [] const hasAudioQuestion = questions.some( (question) => (question.question_type ?? "").toUpperCase() === "AUDIO", ) return hasAudioQuestion ? practice : null } catch { return null } }), ) const speakingPractices = checks.filter((p): p is QuestionSet => p !== null) setPracticeOptions( speakingPractices.map((p) => ({ id: p.id, title: p.title, description: p.description ?? "", persona: p.persona ?? "", status: p.status ?? "", })), ) }, []) useEffect(() => { fetchPracticeOptions().catch(() => { setPracticeOptions([]) }) }, [fetchPracticeOptions]) useEffect(() => { if (!selectedPracticeId) return const exists = practiceOptions.some((option) => option.id === Number(selectedPracticeId)) if (!exists) setSelectedPracticeId("") }, [practiceOptions, selectedPracticeId]) useEffect(() => { let cancelled = false const fetchSubCourseOptions = async () => { setSubCourseLoading(true) try { const categoriesRes = await getCourseCategories() const categories = categoriesRes.data?.data?.categories ?? [] if (categories.length === 0) { if (!cancelled) setSubCourseOptions([]) return } const coursesByCategoryResponses = await Promise.all( categories.map(async (category: CourseCategory) => { const res = await getCoursesByCategory(category.id) return { category, courses: res.data?.data?.courses ?? [], } }), ) const courseRecords = coursesByCategoryResponses.flatMap(({ category, courses }) => courses.map((course: Course) => ({ category, course })), ) const subCourseResponses = await Promise.all( courseRecords.map(async ({ category, course }) => { const res = await getSubModulesByCourse(course.id) const subCourses = res.data?.data?.sub_courses ?? [] return subCourses.map((subCourse: SubCourse) => ({ id: subCourse.id, title: subCourse.title, courseTitle: course.title, categoryName: category.name, })) }), ) const options = subCourseResponses .flat() .sort((a, b) => a.title.localeCompare(b.title)) if (!cancelled) setSubCourseOptions(options) } catch (error) { console.error("Failed to load course options:", error) if (!cancelled) setSubCourseOptions([]) } finally { if (!cancelled) setSubCourseLoading(false) } } fetchSubCourseOptions() return () => { cancelled = true } }, []) useEffect(() => { let cancelled = false const withVoice = audioQuestions.filter((q) => Boolean(q.voice_prompt)) if (withVoice.length === 0) { setAudioPreviewByQuestionId({}) return } const resolveAll = async () => { const entries = await Promise.all( withVoice.map(async (question) => { try { const url = await resolvePreviewUrl(question.voice_prompt ?? "") return [question.id, url] as const } catch { return [question.id, ""] as const } }), ) if (!cancelled) { setAudioPreviewByQuestionId(Object.fromEntries(entries)) } } resolveAll() return () => { cancelled = true } }, [audioQuestions, resolvePreviewUrl]) useEffect(() => { let cancelled = false const withImages = audioQuestions.filter((q) => Boolean(q.image_url)) if (withImages.length === 0) { setImagePreviewByQuestionId({}) return } const resolveAll = async () => { const entries = await Promise.all( withImages.map(async (question) => { try { const url = await resolvePreviewUrl(question.image_url ?? "") return [question.id, url] as const } catch { return [question.id, ""] as const } }), ) if (!cancelled) { setImagePreviewByQuestionId(Object.fromEntries(entries)) } } resolveAll() return () => { cancelled = true } }, [audioQuestions, resolvePreviewUrl]) useEffect(() => { setSelectedQuestionIds([]) }, [selectedPracticeId, audioPage, searchQuery]) const resetCreateForm = () => { setSetTitle("") setSetDescription("") setIntroVideoUrl("") setSubCourseId("") setSetStatus("PUBLISHED") setCreatedSetId(null) setQuestionDrafts([createEmptyDraft()]) } const handleProceedToQuestions = async () => { if (!canProceedToQuestions) return if (createdSetId) { setCurrentStep(2) return } const parsedSubCourseId = Number(subCourseId) if (!Number.isFinite(parsedSubCourseId) || parsedSubCourseId <= 0) { toast.error("Please select a valid sub-course") return } setCreatingSet(true) try { const setRes = await createQuestionSet({ title: setTitle.trim(), ...(setDescription.trim() ? { description: setDescription.trim() } : {}), set_type: "PRACTICE", owner_type: "SUB_COURSE", owner_id: parsedSubCourseId, status: setStatus, ...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}), }) const setId = setRes.data?.data?.id if (!setId) throw new Error("Question set creation failed: missing set ID") setCreatedSetId(setId) setCurrentStep(2) toast.success("Practice created. Continue adding questions.") } catch (error) { console.error("Failed to create speaking practice set:", error) toast.error("Failed to create practice set") } finally { setCreatingSet(false) } } const handleIntroVideoFileChange = async (event: ChangeEvent) => { const file = event.target.files?.[0] event.target.value = "" if (!file) return setUploadingIntroVideo(true) try { const uploadRes = await uploadVideoFile(file, { title: setTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Speaking intro", description: setDescription.trim() || undefined, }) const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data) if (!finalUrl) throw new Error("Missing uploaded video url") setIntroVideoUrl(finalUrl) toast.success("Intro video uploaded", { description: "The URL has been filled in for you." }) } catch (error) { console.error("Failed to upload intro video:", error) toast.error("Failed to upload intro video") } finally { setUploadingIntroVideo(false) } } const canCreate = useMemo(() => { const hasQuestionWithText = questionDrafts.some((draft) => draft.questionText.trim().length > 0) return setTitle.trim().length > 0 && subCourseId.trim().length > 0 && hasQuestionWithText }, [setTitle, subCourseId, questionDrafts]) const canProceedToQuestions = useMemo( () => setTitle.trim().length > 0 && subCourseId.trim().length > 0, [setTitle, subCourseId], ) const questionsWithText = useMemo( () => questionDrafts.filter((draft) => draft.questionText.trim().length > 0), [questionDrafts], ) const canProceedToReview = questionsWithText.length > 0 const selectedSubCourseOption = useMemo( () => subCourseOptions.find((option) => option.id === Number(subCourseId)), [subCourseId, subCourseOptions], ) const filteredSubCourseOptions = useMemo(() => { const q = subCourseSearch.trim().toLowerCase() if (!q) return subCourseOptions return subCourseOptions.filter((option) => { const haystack = `${option.title} ${option.courseTitle} ${option.categoryName} ${option.id}`.toLowerCase() return haystack.includes(q) }) }, [subCourseOptions, subCourseSearch]) const introVideoPreview = useMemo(() => { const value = introVideoUrl.trim() if (!value || !/^https?:\/\//i.test(value)) return null const vimeoEmbedUrl = toVimeoEmbedUrl(value) if (vimeoEmbedUrl) return { kind: "iframe" as const, src: vimeoEmbedUrl } return { kind: "video" as const, src: value } }, [introVideoUrl]) const filteredPracticeOptions = useMemo(() => { const query = practiceFilterSearch.trim().toLowerCase() if (!query) return practiceOptions return practiceOptions.filter((practice) => { const haystack = `${practice.title} ${practice.id}`.toLowerCase() return haystack.includes(query) }) }, [practiceOptions, practiceFilterSearch]) const updateDraft = (index: number, updater: (draft: AudioQuestionDraft) => AudioQuestionDraft) => { setQuestionDrafts((prev) => prev.map((draft, idx) => (idx === index ? updater(draft) : draft))) } const uploadAudioForDraft = useCallback( async (file: File, field: "voice_prompt" | "sample_answer_voice_prompt", draftIndex: number) => { const extension = file.name.split(".").pop()?.toLowerCase() ?? "" if (!ALLOWED_AUDIO_EXTENSIONS.has(extension)) { toast.error("Unsupported audio format") return } if (file.size > MAX_AUDIO_SIZE_BYTES) { toast.error("Audio file must be 50MB or less") return } updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, uploadingVoicePrompt: true } : { ...draft, uploadingSamplePrompt: true }, ) try { const res = await uploadAudioFile(file) const objectKey = res.data?.data?.object_key?.trim() const immediateUrl = res.data?.data?.url?.trim() ?? "" if (!objectKey) throw new Error("Missing uploaded audio object key") if (field === "voice_prompt") { updateDraft(draftIndex, (draft) => ({ ...draft, voicePrompt: objectKey, voicePromptPreviewUrl: immediateUrl || draft.voicePromptPreviewUrl, })) if (!immediateUrl) { const resolvedUrl = await resolvePreviewUrl(objectKey) updateDraft(draftIndex, (draft) => ({ ...draft, voicePromptPreviewUrl: resolvedUrl })) } } else { updateDraft(draftIndex, (draft) => ({ ...draft, sampleAnswerVoicePrompt: objectKey, samplePromptPreviewUrl: immediateUrl || draft.samplePromptPreviewUrl, })) if (!immediateUrl) { const resolvedUrl = await resolvePreviewUrl(objectKey) updateDraft(draftIndex, (draft) => ({ ...draft, samplePromptPreviewUrl: resolvedUrl })) } } toast.success("Audio uploaded successfully") } catch (error) { console.error("Failed to upload audio:", error) toast.error("Failed to upload audio file") } finally { updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, uploadingVoicePrompt: false } : { ...draft, uploadingSamplePrompt: false }, ) } }, [resolvePreviewUrl], ) const handleAudioUpload = async ( event: ChangeEvent, field: "voice_prompt" | "sample_answer_voice_prompt", draftIndex: number, ) => { const file = event.target.files?.[0] if (!file) return await uploadAudioForDraft(file, field, draftIndex) event.target.value = "" } const handleImageUpload = async (event: ChangeEvent, draftIndex: number) => { const file = event.target.files?.[0] if (!file) return const extension = file.name.split(".").pop()?.toLowerCase() ?? "" if (!ALLOWED_IMAGE_EXTENSIONS.has(extension)) { toast.error("Unsupported image format") event.target.value = "" return } if (file.size > MAX_IMAGE_SIZE_BYTES) { toast.error("Image file must be 10MB or less") event.target.value = "" return } updateDraft(draftIndex, (draft) => ({ ...draft, uploadingImage: true })) try { const res = await uploadImageFile(file) const uploadedUrl = res.data?.data?.url?.trim() if (!uploadedUrl) throw new Error("Missing uploaded image url") const resolved = uploadedUrl updateDraft(draftIndex, (draft) => ({ ...draft, imageUrl: uploadedUrl, imagePreviewUrl: resolved, })) toast.success("Image uploaded successfully") } catch (error) { console.error("Failed to upload image:", error) toast.error("Failed to upload image") } finally { updateDraft(draftIndex, (draft) => ({ ...draft, uploadingImage: false })) event.target.value = "" } } const handleResolveManualAudioPreview = async ( field: "voice_prompt" | "sample_answer_voice_prompt", draftIndex: number, ) => { const selectedDraft = questionDrafts[draftIndex] if (!selectedDraft) return const rawValue = field === "voice_prompt" ? selectedDraft.voicePrompt : selectedDraft.sampleAnswerVoicePrompt if (!rawValue.trim()) { updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, voicePromptPreviewUrl: "" } : { ...draft, samplePromptPreviewUrl: "" }, ) return } try { const trimmedValue = rawValue.trim() const isURL = /^https?:\/\//i.test(trimmedValue) if (isURL) { updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, uploadingVoicePrompt: true } : { ...draft, uploadingSamplePrompt: true }, ) const res = await uploadAudioFile(trimmedValue) const objectKey = res.data?.data?.object_key?.trim() const immediateUrl = res.data?.data?.url?.trim() ?? "" if (!objectKey) throw new Error("Missing uploaded audio object key") if (field === "voice_prompt") { updateDraft(draftIndex, (draft) => ({ ...draft, voicePrompt: objectKey, voicePromptPreviewUrl: immediateUrl || draft.voicePromptPreviewUrl, })) if (!immediateUrl) { const resolvedUrl = await resolvePreviewUrl(objectKey) updateDraft(draftIndex, (draft) => ({ ...draft, voicePromptPreviewUrl: resolvedUrl })) } } else { updateDraft(draftIndex, (draft) => ({ ...draft, sampleAnswerVoicePrompt: objectKey, samplePromptPreviewUrl: immediateUrl || draft.samplePromptPreviewUrl, })) if (!immediateUrl) { const resolvedUrl = await resolvePreviewUrl(objectKey) updateDraft(draftIndex, (draft) => ({ ...draft, samplePromptPreviewUrl: resolvedUrl })) } } toast.success("Audio URL imported successfully") return } const url = await resolvePreviewUrl(rawValue) updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, voicePromptPreviewUrl: url } : { ...draft, samplePromptPreviewUrl: url }, ) } catch (error) { console.error("Failed to resolve audio preview URL:", error) toast.error("Could not import/resolve audio URL") } finally { updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, uploadingVoicePrompt: false } : { ...draft, uploadingSamplePrompt: false }, ) } } const handleResolveManualImagePreview = async (draftIndex: number) => { const draft = questionDrafts[draftIndex] if (!draft) return if (!draft.imageUrl.trim()) { updateDraft(draftIndex, (current) => ({ ...current, imagePreviewUrl: "" })) return } try { const resolved = await resolvePreviewUrl(draft.imageUrl) updateDraft(draftIndex, (current) => ({ ...current, imagePreviewUrl: resolved })) } catch (error) { console.error("Failed to resolve image preview URL:", error) toast.error("Could not resolve image preview URL") } } const stopActiveRecording = async (saveRecording: boolean) => { const active = activeRecordingRef.current if (!active) return active.shouldUpload = saveRecording if (active.recorder.state !== "inactive") { active.recorder.stop() return } if (!saveRecording) { active.stream.getTracks().forEach((track) => track.stop()) if (active.rafId) cancelAnimationFrame(active.rafId) await active.audioContext.close().catch(() => undefined) activeRecordingRef.current = null setRecordingModal(null) } } const togglePauseRecording = () => { const active = activeRecordingRef.current if (!active) return if (active.recorder.state === "recording") { active.recorder.pause() active.isPaused = true active.pauseStartedAtMs = Date.now() setRecordingModal((prev) => (prev ? { ...prev, isPaused: true } : prev)) return } if (active.recorder.state === "paused") { active.recorder.resume() if (active.pauseStartedAtMs) { active.pausedTotalMs += Date.now() - active.pauseStartedAtMs } active.pauseStartedAtMs = null active.isPaused = false setRecordingModal((prev) => (prev ? { ...prev, isPaused: false } : prev)) } } const startRecording = async (field: RecordingField, draftIndex: number) => { if (activeRecordingRef.current) { toast.error("Finish current recording first") return } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const recorder = new MediaRecorder(stream) const chunks: BlobPart[] = [] const audioContext = new AudioContext() const analyser = audioContext.createAnalyser() analyser.fftSize = 256 const source = audioContext.createMediaStreamSource(stream) source.connect(analyser) const dataArray = new Uint8Array(analyser.frequencyBinCount) const label = field === "voice_prompt" ? "Voice Prompt" : "Sample Answer" const animateLevels = () => { const active = activeRecordingRef.current if (!active) return const now = Date.now() const effectiveElapsedMs = now - active.startedAtMs - active.pausedTotalMs - (active.isPaused && active.pauseStartedAtMs ? now - active.pauseStartedAtMs : 0) const elapsedSeconds = Math.max(0, Math.floor(effectiveElapsedMs / 1000)) if (!active.isPaused) { analyser.getByteFrequencyData(dataArray) const barCount = 32 const binsPerBar = Math.max(1, Math.floor(dataArray.length / barCount)) const nextLevels = Array.from({ length: barCount }, (_, barIdx) => { const start = barIdx * binsPerBar const end = Math.min(dataArray.length, start + binsPerBar) if (start >= end) return 0.05 let sum = 0 for (let i = start; i < end; i += 1) sum += dataArray[i] const avg = sum / (end - start) // Slight boost curve for clearer realtime movement. const normalized = Math.min(1, Math.pow(avg / 180, 0.85)) return Math.max(0.04, normalized) }) setRecordingModal((prev) => { if (!prev || prev.draftIndex !== draftIndex || prev.field !== field) return prev return { ...prev, levels: nextLevels, elapsedSeconds } }) } else { setRecordingModal((prev) => { if (!prev || prev.draftIndex !== draftIndex || prev.field !== field) return prev return { ...prev, elapsedSeconds } }) } if (activeRecordingRef.current) { activeRecordingRef.current.rafId = requestAnimationFrame(animateLevels) } } recorder.ondataavailable = (event) => { if (event.data.size > 0) chunks.push(event.data) } recorder.onstop = async () => { const active = activeRecordingRef.current if (!active) return const shouldUpload = active.shouldUpload active.stream.getTracks().forEach((track) => track.stop()) if (active.rafId) cancelAnimationFrame(active.rafId) await active.audioContext.close().catch(() => undefined) activeRecordingRef.current = null setRecordingModal(null) if (shouldUpload) { const blob = new Blob(chunks, { type: "audio/webm" }) const fileName = field === "voice_prompt" ? `voice-prompt-${Date.now()}.webm` : `sample-answer-${Date.now()}.webm` const recordedFile = new File([blob], fileName, { type: "audio/webm" }) await uploadAudioForDraft(recordedFile, field, draftIndex) } updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, recordingVoicePrompt: false } : { ...draft, recordingSamplePrompt: false }, ) } setRecordingModal({ draftIndex, field, levels: Array.from({ length: 32 }, () => 0.05), label, elapsedSeconds: 0, isPaused: false, }) recorder.start() activeRecordingRef.current = { recorder, stream, chunks, audioContext, analyser, rafId: null, draftIndex, field, shouldUpload: true, isPaused: false, startedAtMs: Date.now(), pausedTotalMs: 0, pauseStartedAtMs: null, } animateLevels() updateDraft(draftIndex, (draft) => field === "voice_prompt" ? { ...draft, recordingVoicePrompt: true } : { ...draft, recordingSamplePrompt: true }, ) } catch (error) { console.error("Microphone access failed:", error) toast.error("Unable to access microphone") } } useEffect(() => { return () => { const active = activeRecordingRef.current if (!active) return active.shouldUpload = false if (active.recorder.state !== "inactive") active.recorder.stop() active.stream.getTracks().forEach((track) => track.stop()) if (active.rafId) cancelAnimationFrame(active.rafId) active.audioContext.close().catch(() => undefined) } }, []) const handleCreateSpeakingPractice = async () => { if (!canCreate) return const parsedSubCourseId = Number(subCourseId) if (!Number.isFinite(parsedSubCourseId) || parsedSubCourseId <= 0) { toast.error("Please enter a valid Course ID") return } const draftsToCreate = questionDrafts.filter((draft) => draft.questionText.trim().length > 0) if (draftsToCreate.length === 0) { toast.error("Add at least one AUDIO question") return } setSaving(true) try { const setId = createdSetId if (!setId) throw new Error("Question set creation failed: missing set ID") // 2) Create all AUDIO questions then attach in sequence. for (const [idx, draft] of draftsToCreate.entries()) { const questionRes = await createQuestion({ question_text: draft.questionText.trim(), question_type: "AUDIO", status: setStatus, difficulty_level: draft.difficulty || undefined, points: Number.isFinite(draft.points) && draft.points > 0 ? draft.points : 1, voice_prompt: draft.voicePrompt.trim() || undefined, sample_answer_voice_prompt: draft.sampleAnswerVoicePrompt.trim() || undefined, audio_correct_answer_text: draft.audioCorrectAnswerText.trim() || undefined, image_url: draft.imageUrl.trim() || undefined, explanation: draft.explanation.trim() || undefined, tips: draft.tips.trim() || undefined, }) const questionId = questionRes.data?.data?.id if (!questionId) throw new Error("Question creation failed: missing question ID") // 3) Attach each question to created set with display order. await addQuestionToSet(setId, { question_id: questionId, display_order: idx + 1, }) } setOpenCreate(false) setCurrentStep(1) resetCreateForm() await fetchPracticeOptions() setSelectedPracticeId(String(setId)) toast.success(`Speaking practice created with ${draftsToCreate.length} AUDIO question(s)`) await fetchAudioQuestions() } catch (error) { console.error("Failed to create speaking practice:", error) toast.error("Failed to create speaking practice") } finally { setSaving(false) } } const handleOpenQuestionDetail = async (questionId: number) => { setDetailOpen(true) setDetailLoading(true) setSelectedQuestionDetail(null) setDetailVoiceUrl("") setDetailSampleVoiceUrl("") setDetailImageUrl("") try { const res = await getQuestionById(questionId) const detail = res.data?.data ?? null setSelectedQuestionDetail(detail) if (detail) { setDetailForm({ question_text: detail.question_text ?? "", question_type: "AUDIO", difficulty_level: (detail.difficulty_level as "EASY" | "MEDIUM" | "HARD") || "EASY", points: Number(detail.points) > 0 ? Number(detail.points) : 1, explanation: detail.explanation ?? "", tips: detail.tips ?? "", voice_prompt: detail.voice_prompt ?? "", sample_answer_voice_prompt: detail.sample_answer_voice_prompt ?? "", image_url: detail.image_url ?? "", status: (detail.status as "DRAFT" | "PUBLISHED" | "INACTIVE") || "DRAFT", audio_correct_answer_text: detail.audio_correct_answer_text ?? "", }) setDetailEditing(false) const [voice, sample, image] = await Promise.all([ resolvePreviewUrl(detail.voice_prompt ?? ""), resolvePreviewUrl(detail.sample_answer_voice_prompt ?? ""), resolvePreviewUrl(detail.image_url ?? ""), ]) setDetailVoiceUrl(voice) setDetailSampleVoiceUrl(sample) setDetailImageUrl(image) } } catch (error) { console.error("Failed to fetch question detail:", error) toast.error("Failed to load question detail") setDetailOpen(false) } finally { setDetailLoading(false) } } const refreshSelectedQuestionDetail = async (questionId: number) => { const res = await getQuestionById(questionId) const detail = res.data?.data ?? null setSelectedQuestionDetail(detail) if (detail) { const [voice, sample, image] = await Promise.all([ resolvePreviewUrl(detail.voice_prompt ?? ""), resolvePreviewUrl(detail.sample_answer_voice_prompt ?? ""), resolvePreviewUrl(detail.image_url ?? ""), ]) setDetailVoiceUrl(voice) setDetailSampleVoiceUrl(sample) setDetailImageUrl(image) setDetailForm({ question_text: detail.question_text ?? "", question_type: "AUDIO", difficulty_level: (detail.difficulty_level as "EASY" | "MEDIUM" | "HARD") || "EASY", points: Number(detail.points) > 0 ? Number(detail.points) : 1, explanation: detail.explanation ?? "", tips: detail.tips ?? "", voice_prompt: detail.voice_prompt ?? "", sample_answer_voice_prompt: detail.sample_answer_voice_prompt ?? "", image_url: detail.image_url ?? "", status: (detail.status as "DRAFT" | "PUBLISHED" | "INACTIVE") || "DRAFT", audio_correct_answer_text: detail.audio_correct_answer_text ?? "", }) } } const handleUpdateQuestionDetail = async () => { if (!selectedQuestionDetail) return setDetailSaving(true) try { await updateQuestion(selectedQuestionDetail.id, { question_text: detailForm.question_text.trim(), question_type: "AUDIO", difficulty_level: detailForm.difficulty_level, points: Number.isFinite(detailForm.points) && detailForm.points > 0 ? detailForm.points : 1, explanation: detailForm.explanation.trim() || undefined, tips: detailForm.tips.trim() || undefined, voice_prompt: detailForm.voice_prompt.trim() || undefined, sample_answer_voice_prompt: detailForm.sample_answer_voice_prompt.trim() || undefined, image_url: detailForm.image_url.trim() || undefined, status: detailForm.status, audio_correct_answer_text: detailForm.audio_correct_answer_text.trim() || undefined, }) await refreshSelectedQuestionDetail(selectedQuestionDetail.id) await fetchAudioQuestions() setDetailEditing(false) toast.success("AUDIO question updated") } catch (error) { console.error("Failed to update AUDIO question:", error) toast.error("Failed to update AUDIO question") } finally { setDetailSaving(false) } } const handleDeleteQuestionDetail = async () => { const targetId = deleteTarget?.id ?? selectedQuestionDetail?.id if (!targetId) return setDetailDeleting(true) try { await deleteQuestion(targetId) setConfirmDeleteOpen(false) setDeleteTarget(null) setDetailOpen(false) setSelectedQuestionDetail(null) await fetchAudioQuestions() toast.success("AUDIO question deleted") } catch (error) { console.error("Failed to delete AUDIO question:", error) toast.error("Failed to delete AUDIO question") } finally { setDetailDeleting(false) } } const toggleQuestionSelection = (questionId: number) => { setSelectedQuestionIds((prev) => prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId], ) } const toggleSelectAllCurrent = () => { const currentIds = audioQuestions.map((q) => q.id) if (currentIds.length === 0) return const allSelected = currentIds.every((id) => selectedQuestionIds.includes(id)) if (allSelected) { setSelectedQuestionIds((prev) => prev.filter((id) => !currentIds.includes(id))) } else { setSelectedQuestionIds((prev) => Array.from(new Set([...prev, ...currentIds]))) } } const handleBulkDeleteSelected = async () => { if (selectedQuestionIds.length === 0) return setBulkDeleting(true) try { for (const id of selectedQuestionIds) { await deleteQuestion(id) } setSelectedQuestionIds([]) await fetchAudioQuestions() toast.success(`Deleted ${selectedQuestionIds.length} AUDIO question(s)`) } catch (error) { console.error("Failed to delete selected AUDIO questions:", error) toast.error("Failed to delete selected AUDIO questions") } finally { setBulkDeleting(false) } } const togglePracticeCollapsed = (practiceId: number | null) => { if (!practiceId) return setCollapsedPracticeIds((prev) => prev.includes(practiceId) ? prev.filter((id) => id !== practiceId) : [...prev, practiceId], ) } const togglePracticeSelection = (practiceId: number | null) => { if (!practiceId) return const questionIds = audioQuestions.filter((q) => q.practice_id === practiceId).map((q) => q.id) if (questionIds.length === 0) return const allSelected = questionIds.every((id) => selectedQuestionIds.includes(id)) setSelectedQuestionIds((prev) => allSelected ? prev.filter((id) => !questionIds.includes(id)) : Array.from(new Set([...prev, ...questionIds])), ) } const groupedAudioQuestions = useMemo(() => { const groups = new Map() for (const q of audioQuestions) { const key = q.practice_id ? String(q.practice_id) : "unassigned" const title = q.practice_title || "Unknown practice" if (!groups.has(key)) { groups.set(key, { practiceId: q.practice_id, practiceTitle: title, questions: [] }) } groups.get(key)?.questions.push(q) } return Array.from(groups.values()) }, [audioQuestions]) return (

Speaking

Create and manage speaking practice sessions for your learners.

{!openCreate && ( AUDIO questions

Tap a row to view details. Speaking practices create AUDIO question sets linked to a sub-course.

setPracticeFilterSearch(e.target.value)} placeholder="Search practices..." className="mb-2 h-9" />
{ setSelectedPracticeId(value) setAudioPage(1) setPracticeFilterOpen(false) }} > All practices {filteredPracticeOptions.map((practice) => ( {practice.title} (#{practice.id}) ))}
{ setSearchQuery(e.target.value) setAudioPage(1) }} placeholder="Search question text, answer text, or practice..." className="h-10" />

Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)

{loading ? (
Loading audio questions...
) : audioQuestions.length === 0 ? (

No audio questions yet

Create a speaking practice to automatically create and attach an AUDIO question.

) : (
{groupedAudioQuestions.map((group) => (
{(group.practiceId && collapsedPracticeIds.includes(group.practiceId) ? [] : group.questions).map((question, idx) => (
handleOpenQuestionDetail(question.id)} >
event.stopPropagation()} onChange={() => toggleQuestionSelection(question.id)} className="mt-1" />

{question.question_text}

AUDIO Difficulty: {question.difficulty_level || "—"} Points: {question.points ?? 0} Status: {question.status || "—"}
{question.image_url && imagePreviewByQuestionId[question.id] ? (

Image preview

Question visual
) : null} {question.voice_prompt ? (

Voice prompt preview

{audioPreviewByQuestionId[question.id] ? (
) : null} {question.audio_correct_answer_text ? (

Correct answer text:{" "} {question.audio_correct_answer_text}

) : null}
))}
))} {audioTotalCount > audioPageSize ? (

Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}

) : null}
)}
)} {detailOpen && (
setDetailOpen(false)} >
e.stopPropagation()} >

AUDIO question

{!detailLoading && selectedQuestionDetail && !detailEditing ? ( ) : null} {!detailLoading && selectedQuestionDetail ? ( ) : null}
{detailLoading ? (
) : selectedQuestionDetail && detailEditing ? ( <>