Yimaru-Admin/src/components/content-management/PracticeQuestionEditorFields.tsx
Yared Yemane 06af3a97f2 enhance human language practice editing and collapsible hierarchy
Expand edit-practice modal to include full question-set metadata fields, raise recorder modal overlay, and add module/sub-module collapse toggles to match path and level expand/collapse behavior.

Made-with: Cursor
2026-04-08 01:53:48 -07:00

990 lines
37 KiB
TypeScript

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<Record<PracticeQuestionFieldErrorKey, string>>
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<PracticeQuestionEditorValue>) => {
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<PracticeQuestionOptionDraft>) => {
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<HTMLInputElement>,
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<HTMLInputElement>) => {
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 (
<>
<div className="mt-5 space-y-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
<textarea
value={value.questionText}
onChange={(e) => patch({ questionText: e.target.value })}
placeholder="Enter your question..."
className={cn(
"w-full rounded-lg border px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100",
showFieldErrors && fieldErrors.questionText ? "border-red-300 ring-1 ring-red-200" : "border-grayScale-200",
)}
rows={2}
aria-invalid={Boolean(showFieldErrors && fieldErrors.questionText)}
/>
{showFieldErrors && fieldErrors.questionText ? (
<p className="text-xs text-red-600">{fieldErrors.questionText}</p>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label>
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option>
<option value="AUDIO">Audio</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
<Select
value={value.difficultyLevel}
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
<Input
type="number"
value={value.points}
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
min={1}
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
/>
{showFieldErrors && fieldErrors.points ? (
<p className="text-xs text-red-600">{fieldErrors.points}</p>
) : null}
</div>
</div>
{value.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
<div className="space-y-2.5">
{value.options.map((option, optIdx) => (
<div
key={optIdx}
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors ${
option.isCorrect ? "border-green-200 bg-green-50/50" : "border-grayScale-200 bg-white"
}`}
>
<button
type="button"
onClick={() => setCorrectOption(optIdx)}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
option.isCorrect
? "border-green-500 bg-green-500 text-white shadow-sm"
: "border-grayScale-300 hover:border-brand-400 hover:shadow-sm"
}`}
>
{option.isCorrect ? <Check className="h-3 w-3" /> : null}
</button>
<Input
value={option.text}
onChange={(e) => updateOption(optIdx, { text: e.target.value })}
placeholder={`Option ${optIdx + 1}`}
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0"
/>
{value.options.length > 2 ? (
<button
type="button"
onClick={() => removeOption(optIdx)}
className="rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
))}
<button
type="button"
onClick={addOption}
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Plus className="h-4 w-4" />
Add Option
</button>
</div>
<p className="text-xs text-grayScale-400">Click the circle to mark the correct answer.</p>
{showFieldErrors && fieldErrors.options ? (
<p className="text-xs text-red-600">{fieldErrors.options}</p>
) : null}
{showFieldErrors && fieldErrors.correctOption ? (
<p className="text-xs text-red-600">{fieldErrors.correctOption}</p>
) : null}
</div>
)}
{value.questionType === "TRUE_FALSE" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Correct Answer</label>
<div className="flex gap-3">
{["True", "False"].map((val, i) => (
<button
key={val}
type="button"
onClick={() =>
patch({
options: [
{ text: "True", isCorrect: i === 0 },
{ text: "False", isCorrect: i === 1 },
],
})
}
className={`flex-1 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
value.options[i]?.isCorrect
? "border-green-500 bg-green-50 text-green-700"
: "border-grayScale-200 text-grayScale-600 hover:border-grayScale-300"
}`}
>
{val}
</button>
))}
</div>
</div>
)}
{value.questionType === "SHORT" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Expected Short Answer</label>
<Input
value={value.shortAnswer}
onChange={(e) => patch({ shortAnswer: e.target.value })}
placeholder="Enter the acceptable answer"
className={cn(showFieldErrors && fieldErrors.shortAnswer ? "border-red-300 ring-1 ring-red-200" : undefined)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.shortAnswer)}
/>
{showFieldErrors && fieldErrors.shortAnswer ? (
<p className="text-xs text-red-600">{fieldErrors.shortAnswer}</p>
) : null}
</div>
)}
{value.questionType === "AUDIO" && (
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Audio Correct Answer Text</label>
<Input
value={value.audioCorrectAnswerText}
onChange={(e) => patch({ audioCorrectAnswerText: e.target.value })}
placeholder="Expected correct answer text for audio response"
/>
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input
value={value.tips}
onChange={(e) => patch({ tips: e.target.value })}
placeholder="Helpful tip for the student"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Explanation (Optional)</label>
<Input
value={value.explanation}
onChange={(e) => patch({ explanation: e.target.value })}
placeholder="Why this is the correct answer"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<label
className={cn(
"inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-sm text-grayScale-600 hover:bg-grayScale-50",
(uploadingVoice || controlsDisabled || recordingModal) && "pointer-events-none opacity-60",
)}
>
{uploadingVoice ? <SpinnerIcon className="h-4 w-4" /> : <Upload className="h-4 w-4" />}
Upload Voice Prompt
<input
type="file"
accept=".mp3,.wav,.ogg,.m4a,.aac,.webm,.flac,audio/*"
className="hidden"
onChange={(e) => void handleAudioFileInput(e, "voice_prompt")}
disabled={uploadingVoice || controlsDisabled || Boolean(recordingModal)}
/>
</label>
<Button
type="button"
variant="outline"
className="h-9 px-3 text-xs"
onClick={() => void startRecording("voice_prompt")}
disabled={uploadingVoice || controlsDisabled || Boolean(recordingModal)}
>
{recordingModal?.field === "voice_prompt" ? "Recording…" : "Record Voice Prompt"}
</Button>
<span className="text-xs text-grayScale-400">Max 50MB</span>
</div>
<Input
value={value.voicePrompt}
onChange={(e) => patch({ voicePrompt: e.target.value })}
onBlur={() => void resolveManualAudioOnBlur("voice_prompt")}
placeholder="Audio object_key (or URL)"
className="font-mono text-[13px]"
disabled={controlsDisabled}
/>
{voicePreviewUrl ? (
<audio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
) : null}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Sample Answer Voice Prompt (Optional)
</label>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<label
className={cn(
"inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-sm text-grayScale-600 hover:bg-grayScale-50",
(uploadingSample || controlsDisabled || recordingModal) && "pointer-events-none opacity-60",
)}
>
{uploadingSample ? <SpinnerIcon className="h-4 w-4" /> : <Upload className="h-4 w-4" />}
Upload Sample Answer
<input
type="file"
accept=".mp3,.wav,.ogg,.m4a,.aac,.webm,.flac,audio/*"
className="hidden"
onChange={(e) => void handleAudioFileInput(e, "sample_answer_voice_prompt")}
disabled={uploadingSample || controlsDisabled || Boolean(recordingModal)}
/>
</label>
<Button
type="button"
variant="outline"
className="h-9 px-3 text-xs"
onClick={() => void startRecording("sample_answer_voice_prompt")}
disabled={uploadingSample || controlsDisabled || Boolean(recordingModal)}
>
{recordingModal?.field === "sample_answer_voice_prompt" ? "Recording…" : "Record Sample Answer"}
</Button>
<span className="text-xs text-grayScale-400">Max 50MB</span>
</div>
<Input
value={value.sampleAnswerVoicePrompt}
onChange={(e) => patch({ sampleAnswerVoicePrompt: e.target.value })}
onBlur={() => void resolveManualAudioOnBlur("sample_answer_voice_prompt")}
placeholder="Audio object_key (or URL)"
className="font-mono text-[13px]"
disabled={controlsDisabled}
/>
{samplePreviewUrl ? (
<audio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
) : null}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Image (Optional)</label>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<label
className={cn(
"inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-sm text-grayScale-600 hover:bg-grayScale-50",
(uploadingImage || controlsDisabled) && "pointer-events-none opacity-60",
)}
>
{uploadingImage ? <SpinnerIcon className="h-4 w-4" /> : <ImageIcon className="h-4 w-4" />}
Upload Image
<input
type="file"
accept=".jpg,.jpeg,.png,.webp,.gif,image/*"
className="hidden"
onChange={(e) => void handleImageFileInput(e)}
disabled={uploadingImage || controlsDisabled}
/>
</label>
<span className="text-xs text-grayScale-400">Max 10MB</span>
</div>
<Input
value={value.imageUrl}
onChange={(e) => patch({ imageUrl: e.target.value })}
onBlur={() => void resolveImageOnBlur()}
placeholder="Image URL (https://…) or key"
className="font-mono text-[13px]"
disabled={controlsDisabled}
/>
{imagePreviewUrl ? (
<img
src={imagePreviewUrl}
alt=""
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"
/>
) : null}
</div>
</div>
</div>
{recordingModal ? (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white p-6 shadow-2xl">
<p className="text-center text-base font-semibold text-grayScale-900">Recording {recordingModal.label}</p>
<p className="mt-1 text-center text-xs text-grayScale-500">
Speak clearly. The bars reflect your input level in real time.
</p>
<div className="mt-3 flex justify-center">
<div className="inline-flex items-center gap-2 rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-800">
<span className="h-2 w-2 animate-pulse rounded-full bg-brand-500" />
REC{" "}
{`${Math.floor(recordingModal.elapsedSeconds / 60)
.toString()
.padStart(2, "0")}:${(recordingModal.elapsedSeconds % 60).toString().padStart(2, "0")}`}
</div>
</div>
<div className="mt-5 flex items-center gap-4 rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-4">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-700 shadow-sm">
<Mic className="h-5 w-5" />
</div>
<div
className="grid h-16 w-full items-end gap-[3px] overflow-hidden"
style={{ gridTemplateColumns: "repeat(32, minmax(0, 1fr))" }}
>
{recordingModal.levels.map((level, idx) => {
const segmentCount = 9
const activeSegments = Math.max(1, Math.min(segmentCount, Math.round(level * segmentCount)))
return (
<div key={idx} className="flex h-full w-full flex-col-reverse gap-[2px]">
{Array.from({ length: segmentCount }, (_, segmentIdx) => (
<div
key={segmentIdx}
className="h-[5px] rounded-[2px]"
style={{
background:
segmentIdx < activeSegments
? "linear-gradient(180deg, #9E2891 0%, #6A1B9A 100%)"
: "rgba(189, 189, 189, 0.35)",
}}
/>
))}
</div>
)
})}
</div>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
className="border-grayScale-200 text-grayScale-700 hover:bg-grayScale-50"
onClick={() => void stopActiveRecording(false)}
>
Cancel
</Button>
<Button
type="button"
variant="outline"
className="border-grayScale-200 text-grayScale-700 hover:bg-grayScale-50"
onClick={togglePauseRecording}
>
{recordingModal.isPaused ? "Continue" : "Pause"}
</Button>
<Button
type="button"
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => void stopActiveRecording(true)}
>
Stop & Save
</Button>
</div>
</div>
</div>
) : null}
</>
)
}