import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { ArrowLeft, ChevronDown, 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, getSubCoursesByCourse, createQuestion, createQuestionSet, getQuestions, updateQuestion, } from "../../api/courses.api" import { resolveFileUrl, uploadAudioFile, uploadImageFile } 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, 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 } 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 } export function SpeakingPage() { const [audioQuestions, setAudioQuestions] = useState([]) const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = 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 [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">("DRAFT") 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 "AUDIO", 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 () => { setLoading(true) try { const batchSize = 100 let nextOffset = 0 let expectedTotal = Number.POSITIVE_INFINITY let allRows: QuestionDetail[] = [] while (allRows.length < expectedTotal) { const res = await getQuestions({ question_type: "AUDIO", limit: batchSize, offset: nextOffset, }) const payload = res.data?.data as unknown const meta = res.data?.metadata as { total_count?: number } | null | undefined let chunk: QuestionDetail[] = [] let chunkTotal: number | undefined if (Array.isArray(payload)) { chunk = payload as QuestionDetail[] chunkTotal = meta?.total_count } else if ( payload && typeof payload === "object" && Array.isArray((payload as { questions?: unknown[] }).questions) ) { const data = payload as { questions: QuestionDetail[]; total_count?: number } chunk = data.questions chunkTotal = data.total_count ?? meta?.total_count } allRows = [...allRows, ...chunk] if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) { expectedTotal = chunkTotal } if (chunk.length < batchSize) break nextOffset += chunk.length } setAudioQuestions(allRows) } catch (error) { console.error("Failed to fetch audio questions:", error) setAudioQuestions([]) } finally { setLoading(false) } }, []) useEffect(() => { fetchAudioQuestions() }, [fetchAudioQuestions]) 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 getSubCoursesByCourse(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]) const resetCreateForm = () => { setSetTitle("") setSetDescription("") setSubCourseId("") setSetStatus("DRAFT") setQuestionDrafts([createEmptyDraft()]) } 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 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 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 resolve audio preview URL") } } 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 { // 1) Create speaking practice set. const setRes = await createQuestionSet({ title: setTitle.trim(), description: setDescription.trim(), set_type: "PRACTICE", owner_type: "SUB_COURSE", owner_id: parsedSubCourseId, status: setStatus, }) const setId = setRes.data?.data?.id 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() 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) } } return (

Speaking

Create and manage speaking practice sessions for your learners.

{!openCreate && ( AUDIO Questions {loading ? (
Loading audio questions...
) : audioQuestions.length === 0 ? (

No audio questions yet

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

) : (
{audioQuestions.map((question, idx) => (
handleOpenQuestionDetail(question.id)} >

{question.question_text}

AUDIO Difficulty: {question.difficulty_level || "—"} Points: {question.points ?? 0} Status: {question.status || "—"}
{question.voice_prompt ? (

Voice prompt preview

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

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

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

AUDIO Question Detail

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