import { useCallback, useEffect, useRef, useState, type ChangeEvent, } from "react" import { Check, Image as ImageIcon, Mic, Plus, Upload, X } from "lucide-react" import { toast } from "sonner" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { uploadAudioFile, uploadImageFile } from "../../api/files.api" import { Input } from "../ui/input" import { Select } from "../ui/select" import { Button } from "../ui/button" import { SpinnerIcon } from "../ui/spinner-icon" import { cn } from "../../lib/utils" export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD" export interface PracticeQuestionOptionDraft { text: string isCorrect: boolean } type RecordingField = "voice_prompt" | "sample_answer_voice_prompt" 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"]) export interface PracticeQuestionEditorValue { questionText: string questionType: PracticeQuestionEditorType difficultyLevel: PracticeQuestionEditorDifficulty points: number tips: string explanation: string options: PracticeQuestionOptionDraft[] voicePrompt: string sampleAnswerVoicePrompt: string audioCorrectAnswerText: string shortAnswer: string /** Stored URL or object key; same semantics as Speaking practice editor */ imageUrl: string } export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue { return { questionText: "", questionType: "MCQ", difficultyLevel: "EASY", points: 1, tips: "", explanation: "", options: [ { text: "", isCorrect: true }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, ], voicePrompt: "", sampleAnswerVoicePrompt: "", audioCorrectAnswerText: "", shortAnswer: "", imageUrl: "", } } function useDebouncedString(value: string, delayMs: number) { const [debounced, setDebounced] = useState(value) useEffect(() => { const t = window.setTimeout(() => setDebounced(value), delayMs) return () => window.clearTimeout(t) }, [value, delayMs]) return debounced } function defaultOptionsForType( type: PracticeQuestionEditorType, previousType: PracticeQuestionEditorType, current: PracticeQuestionOptionDraft[], ): PracticeQuestionOptionDraft[] { if (type === "TRUE_FALSE") { if (previousType === "TRUE_FALSE" && current.length >= 2) { return current.map((o, i) => ({ text: i === 0 ? "True" : "False", isCorrect: o.isCorrect, })) } return [ { text: "True", isCorrect: true }, { text: "False", isCorrect: false }, ] } if (type === "MCQ") { if (previousType === "MCQ" && current.length >= 2) { const hasCorrect = current.some((o) => o.isCorrect) return current.map((o, i) => ({ text: o.text, isCorrect: hasCorrect ? o.isCorrect : i === 0, })) } return [ { text: "", isCorrect: true }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, { text: "", isCorrect: false }, ] } return current } export type PracticeQuestionFieldErrorKey = | "questionText" | "points" | "shortAnswer" | "options" | "correctOption" export interface PracticeQuestionEditorFieldsProps { value: PracticeQuestionEditorValue onChange: (next: PracticeQuestionEditorValue) => void fieldErrors?: Partial> showFieldErrors?: boolean /** Disables upload/record controls while parent saves */ mediaBusy?: boolean } export function PracticeQuestionEditorFields({ value, onChange, fieldErrors = {}, showFieldErrors = false, mediaBusy = false, }: PracticeQuestionEditorFieldsProps) { const valueRef = useRef(value) valueRef.current = value const patch = (partial: Partial) => { onChange({ ...value, ...partial }) } const setType = (questionType: PracticeQuestionEditorType) => { const options = defaultOptionsForType(questionType, value.questionType, value.options) onChange({ ...value, questionType, options }) } const updateOption = (optionIndex: number, updates: Partial) => { const options = value.options.map((opt, i) => (i === optionIndex ? { ...opt, ...updates } : opt)) onChange({ ...value, options }) } const addOption = () => { onChange({ ...value, options: [...value.options, { text: "", isCorrect: false }] }) } const removeOption = (optionIndex: number) => { if (value.options.length <= 2) return const options = value.options.filter((_, i) => i !== optionIndex) if (!options.some((o) => o.isCorrect) && options.length > 0) { options[0] = { ...options[0], isCorrect: true } } onChange({ ...value, options }) } const setCorrectOption = (optionIndex: number) => { const options = value.options.map((opt, i) => ({ ...opt, isCorrect: i === optionIndex })) onChange({ ...value, options }) } const [voicePreviewUrl, setVoicePreviewUrl] = useState("") const [samplePreviewUrl, setSamplePreviewUrl] = useState("") const [imagePreviewUrl, setImagePreviewUrl] = useState("") const [uploadingVoice, setUploadingVoice] = useState(false) const [uploadingSample, setUploadingSample] = useState(false) const [uploadingImage, setUploadingImage] = useState(false) const debVoice = useDebouncedString(value.voicePrompt, 500) const debSample = useDebouncedString(value.sampleAnswerVoicePrompt, 500) const debImage = useDebouncedString(value.imageUrl, 500) useEffect(() => { let cancelled = false ;(async () => { const s = debVoice.trim() if (!s) { setVoicePreviewUrl("") return } if (s.startsWith("http://") || s.startsWith("https://")) { setVoicePreviewUrl(s) return } try { const u = await resolveMediaPreviewUrl(s) if (!cancelled) setVoicePreviewUrl(u) } catch { if (!cancelled) setVoicePreviewUrl("") } })() return () => { cancelled = true } }, [debVoice]) useEffect(() => { let cancelled = false ;(async () => { const s = debSample.trim() if (!s) { setSamplePreviewUrl("") return } if (s.startsWith("http://") || s.startsWith("https://")) { setSamplePreviewUrl(s) return } try { const u = await resolveMediaPreviewUrl(s) if (!cancelled) setSamplePreviewUrl(u) } catch { if (!cancelled) setSamplePreviewUrl("") } })() return () => { cancelled = true } }, [debSample]) useEffect(() => { let cancelled = false ;(async () => { const s = debImage.trim() if (!s) { setImagePreviewUrl("") return } try { const u = await resolveMediaPreviewUrl(s) if (!cancelled) setImagePreviewUrl(u) } catch { if (!cancelled) setImagePreviewUrl("") } })() return () => { cancelled = true } }, [debImage]) const uploadAudioForField = useCallback( async (file: File, field: RecordingField) => { 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 } if (field === "voice_prompt") setUploadingVoice(true) else setUploadingSample(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") const base = valueRef.current if (field === "voice_prompt") { onChange({ ...base, voicePrompt: objectKey, }) setVoicePreviewUrl(immediateUrl || (await resolveMediaPreviewUrl(objectKey))) } else { onChange({ ...base, sampleAnswerVoicePrompt: objectKey, }) setSamplePreviewUrl(immediateUrl || (await resolveMediaPreviewUrl(objectKey))) } toast.success("Audio uploaded successfully") } catch (error) { console.error("Failed to upload audio:", error) toast.error("Failed to upload audio file") } finally { if (field === "voice_prompt") setUploadingVoice(false) else setUploadingSample(false) } }, [onChange], ) const handleAudioFileInput = async ( event: ChangeEvent, field: RecordingField, ) => { const file = event.target.files?.[0] event.target.value = "" if (!file) return await uploadAudioForField(file, field) } const resolveManualAudioOnBlur = async (field: RecordingField) => { const rawValue = field === "voice_prompt" ? value.voicePrompt : value.sampleAnswerVoicePrompt if (!rawValue.trim()) { if (field === "voice_prompt") setVoicePreviewUrl("") else setSamplePreviewUrl("") return } try { const trimmedValue = rawValue.trim() const isURL = /^https?:\/\//i.test(trimmedValue) if (isURL) { if (field === "voice_prompt") setUploadingVoice(true) else setUploadingSample(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") const base = valueRef.current if (field === "voice_prompt") { onChange({ ...base, voicePrompt: objectKey }) setVoicePreviewUrl(immediateUrl || (await resolveMediaPreviewUrl(objectKey))) } else { onChange({ ...base, sampleAnswerVoicePrompt: objectKey }) setSamplePreviewUrl(immediateUrl || (await resolveMediaPreviewUrl(objectKey))) } toast.success("Audio URL imported successfully") return } const url = await resolveMediaPreviewUrl(rawValue) if (field === "voice_prompt") setVoicePreviewUrl(url) else setSamplePreviewUrl(url) } catch (error) { console.error("Failed to resolve audio:", error) toast.error("Could not import/resolve audio URL") } finally { if (field === "voice_prompt") setUploadingVoice(false) else setUploadingSample(false) } } const handleImageFileInput = async (event: ChangeEvent) => { const file = event.target.files?.[0] event.target.value = "" if (!file) return const extension = file.name.split(".").pop()?.toLowerCase() ?? "" if (!ALLOWED_IMAGE_EXTENSIONS.has(extension)) { toast.error("Unsupported image format") return } if (file.size > MAX_IMAGE_SIZE_BYTES) { toast.error("Image file must be 10MB or less") return } setUploadingImage(true) try { const res = await uploadImageFile(file) const uploadedUrl = res.data?.data?.url?.trim() if (!uploadedUrl) throw new Error("Missing uploaded image url") onChange({ ...valueRef.current, imageUrl: uploadedUrl }) setImagePreviewUrl(uploadedUrl) toast.success("Image uploaded successfully") } catch (error) { console.error("Failed to upload image:", error) toast.error("Failed to upload image") } finally { setUploadingImage(false) } } const resolveImageOnBlur = async () => { if (!value.imageUrl.trim()) { setImagePreviewUrl("") return } try { const resolved = await resolveMediaPreviewUrl(value.imageUrl) setImagePreviewUrl(resolved) } catch (error) { console.error("Failed to resolve image preview URL:", error) toast.error("Could not resolve image preview URL") } } const [recordingModal, setRecordingModal] = useState<{ 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 field: RecordingField shouldUpload: boolean isPaused: boolean startedAtMs: number pausedTotalMs: number pauseStartedAtMs: number | null } | null>(null) 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) => { 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) const normalized = Math.min(1, Math.pow(avg / 180, 0.85)) return Math.max(0.04, normalized) }) setRecordingModal((prev) => { if (!prev || prev.field !== field) return prev return { ...prev, levels: nextLevels, elapsedSeconds } }) } else { setRecordingModal((prev) => { if (!prev || 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 const stopField = active.field 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 = stopField === "voice_prompt" ? `voice-prompt-${Date.now()}.webm` : `sample-answer-${Date.now()}.webm` const recordedFile = new File([blob], fileName, { type: "audio/webm" }) await uploadAudioForField(recordedFile, stopField) } } setRecordingModal({ field, levels: Array.from({ length: 32 }, () => 0.05), label, elapsedSeconds: 0, isPaused: false, }) recorder.start() activeRecordingRef.current = { recorder, stream, chunks, audioContext, analyser, rafId: null, field, shouldUpload: true, isPaused: false, startedAtMs: Date.now(), pausedTotalMs: 0, pauseStartedAtMs: null, } animateLevels() } 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 controlsDisabled = mediaBusy return ( <>