media preview fix
This commit is contained in:
parent
4210a05ba9
commit
d1842579e9
|
|
@ -1,6 +1,18 @@
|
||||||
import { Check, Plus, X } from "lucide-react"
|
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 { Input } from "../ui/input"
|
||||||
import { Select } from "../ui/select"
|
import { Select } from "../ui/select"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
|
|
@ -11,6 +23,13 @@ export interface PracticeQuestionOptionDraft {
|
||||||
isCorrect: boolean
|
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 {
|
export interface PracticeQuestionEditorValue {
|
||||||
questionText: string
|
questionText: string
|
||||||
questionType: PracticeQuestionEditorType
|
questionType: PracticeQuestionEditorType
|
||||||
|
|
@ -23,6 +42,8 @@ export interface PracticeQuestionEditorValue {
|
||||||
sampleAnswerVoicePrompt: string
|
sampleAnswerVoicePrompt: string
|
||||||
audioCorrectAnswerText: string
|
audioCorrectAnswerText: string
|
||||||
shortAnswer: string
|
shortAnswer: string
|
||||||
|
/** Stored URL or object key; same semantics as Speaking practice editor */
|
||||||
|
imageUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue {
|
export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue {
|
||||||
|
|
@ -43,9 +64,19 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue
|
||||||
sampleAnswerVoicePrompt: "",
|
sampleAnswerVoicePrompt: "",
|
||||||
audioCorrectAnswerText: "",
|
audioCorrectAnswerText: "",
|
||||||
shortAnswer: "",
|
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(
|
function defaultOptionsForType(
|
||||||
type: PracticeQuestionEditorType,
|
type: PracticeQuestionEditorType,
|
||||||
previousType: PracticeQuestionEditorType,
|
previousType: PracticeQuestionEditorType,
|
||||||
|
|
@ -93,6 +124,8 @@ export interface PracticeQuestionEditorFieldsProps {
|
||||||
onChange: (next: PracticeQuestionEditorValue) => void
|
onChange: (next: PracticeQuestionEditorValue) => void
|
||||||
fieldErrors?: Partial<Record<PracticeQuestionFieldErrorKey, string>>
|
fieldErrors?: Partial<Record<PracticeQuestionFieldErrorKey, string>>
|
||||||
showFieldErrors?: boolean
|
showFieldErrors?: boolean
|
||||||
|
/** Disables upload/record controls while parent saves */
|
||||||
|
mediaBusy?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PracticeQuestionEditorFields({
|
export function PracticeQuestionEditorFields({
|
||||||
|
|
@ -100,7 +133,11 @@ export function PracticeQuestionEditorFields({
|
||||||
onChange,
|
onChange,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
showFieldErrors = false,
|
showFieldErrors = false,
|
||||||
|
mediaBusy = false,
|
||||||
}: PracticeQuestionEditorFieldsProps) {
|
}: PracticeQuestionEditorFieldsProps) {
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
valueRef.current = value
|
||||||
|
|
||||||
const patch = (partial: Partial<PracticeQuestionEditorValue>) => {
|
const patch = (partial: Partial<PracticeQuestionEditorValue>) => {
|
||||||
onChange({ ...value, ...partial })
|
onChange({ ...value, ...partial })
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +170,422 @@ export function PracticeQuestionEditorFields({
|
||||||
onChange({ ...value, options })
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<div className="mt-5 space-y-5">
|
<div className="mt-5 space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
|
||||||
|
|
@ -293,6 +745,17 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</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="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
|
||||||
|
|
@ -312,37 +775,215 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
|
<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
|
<Input
|
||||||
value={value.voicePrompt}
|
value={value.voicePrompt}
|
||||||
onChange={(e) => patch({ voicePrompt: e.target.value })}
|
onChange={(e) => patch({ voicePrompt: e.target.value })}
|
||||||
placeholder="Voice prompt text"
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
Sample Answer Voice Prompt (Optional)
|
Sample Answer Voice Prompt (Optional)
|
||||||
</label>
|
</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
|
<Input
|
||||||
value={value.sampleAnswerVoicePrompt}
|
value={value.sampleAnswerVoicePrompt}
|
||||||
onChange={(e) => patch({ sampleAnswerVoicePrompt: e.target.value })}
|
onChange={(e) => patch({ sampleAnswerVoicePrompt: e.target.value })}
|
||||||
placeholder="Sample answer voice prompt"
|
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>
|
</div>
|
||||||
|
|
||||||
{value.questionType === "AUDIO" && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Audio Correct Answer Text</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Image (Optional)</label>
|
||||||
<Input
|
<div className="flex flex-col gap-2">
|
||||||
value={value.audioCorrectAnswerText}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
onChange={(e) => patch({ audioCorrectAnswerText: e.target.value })}
|
<label
|
||||||
placeholder="Expected correct answer text for audio response"
|
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",
|
||||||
</div>
|
(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-[70] flex items-center justify-center bg-black/40 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>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/lib/practiceMedia.ts
Normal file
21
src/lib/practiceMedia.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { resolveFileUrl } from "../api/files.api"
|
||||||
|
|
||||||
|
export function normalizeObjectKey(value: string): 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 async function resolveMediaPreviewUrl(value: string): Promise<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 ?? ""
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ interface Question {
|
||||||
sampleAnswerVoicePrompt: string
|
sampleAnswerVoicePrompt: string
|
||||||
audioCorrectAnswerText: string
|
audioCorrectAnswerText: string
|
||||||
shortAnswers: string[]
|
shortAnswers: string[]
|
||||||
|
imageUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERSONAS: Persona[] = [
|
const PERSONAS: Persona[] = [
|
||||||
|
|
@ -91,6 +92,7 @@ function createEmptyQuestion(id: string): Question {
|
||||||
sampleAnswerVoicePrompt: "",
|
sampleAnswerVoicePrompt: "",
|
||||||
audioCorrectAnswerText: "",
|
audioCorrectAnswerText: "",
|
||||||
shortAnswers: [],
|
shortAnswers: [],
|
||||||
|
imageUrl: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,6 +234,7 @@ export function AddNewPracticePage() {
|
||||||
voice_prompt: q.voicePrompt || undefined,
|
voice_prompt: q.voicePrompt || undefined,
|
||||||
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||||
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
||||||
|
image_url: q.imageUrl.trim() || undefined,
|
||||||
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -606,6 +609,7 @@ export function AddNewPracticePage() {
|
||||||
sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
|
sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
|
||||||
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
||||||
shortAnswer: question.shortAnswers[0] ?? "",
|
shortAnswer: question.shortAnswers[0] ?? "",
|
||||||
|
imageUrl: question.imageUrl,
|
||||||
}}
|
}}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
updateQuestion(question.id, {
|
updateQuestion(question.id, {
|
||||||
|
|
@ -620,8 +624,10 @@ export function AddNewPracticePage() {
|
||||||
sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
|
sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
|
||||||
audioCorrectAnswerText: next.audioCorrectAnswerText,
|
audioCorrectAnswerText: next.audioCorrectAnswerText,
|
||||||
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
|
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
|
||||||
|
imageUrl: next.imageUrl,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
mediaBusy={saving}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,6 @@ export function HumanLanguagePage() {
|
||||||
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
|
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
|
||||||
const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" })
|
const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" })
|
||||||
const [questionDraft, setQuestionDraft] = useState<PracticeQuestionEditorValue>(() => createEmptyPracticeQuestionDraft())
|
const [questionDraft, setQuestionDraft] = useState<PracticeQuestionEditorValue>(() => createEmptyPracticeQuestionDraft())
|
||||||
const [questionImageUrl, setQuestionImageUrl] = useState("")
|
|
||||||
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
|
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
|
||||||
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
|
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
|
||||||
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
|
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
|
||||||
|
|
@ -597,7 +596,6 @@ export function HumanLanguagePage() {
|
||||||
const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" })
|
const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" })
|
||||||
const resetQuestionForm = () => {
|
const resetQuestionForm = () => {
|
||||||
setQuestionDraft(createEmptyPracticeQuestionDraft())
|
setQuestionDraft(createEmptyPracticeQuestionDraft())
|
||||||
setQuestionImageUrl("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreatePracticeDialog = (subModuleId: number) => {
|
const openCreatePracticeDialog = (subModuleId: number) => {
|
||||||
|
|
@ -680,7 +678,6 @@ export function HumanLanguagePage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setQuestionDetailById((prev) => ({ ...prev, [qid]: detail }))
|
setQuestionDetailById((prev) => ({ ...prev, [qid]: detail }))
|
||||||
setQuestionImageUrl(detail.image_url ?? "")
|
|
||||||
const sortedOpts = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order)
|
const sortedOpts = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order)
|
||||||
const shortAnswer =
|
const shortAnswer =
|
||||||
Array.isArray(detail.short_answers) && detail.short_answers.length > 0
|
Array.isArray(detail.short_answers) && detail.short_answers.length > 0
|
||||||
|
|
@ -738,6 +735,7 @@ export function HumanLanguagePage() {
|
||||||
sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "",
|
sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "",
|
||||||
audioCorrectAnswerText: detail.audio_correct_answer_text ?? "",
|
audioCorrectAnswerText: detail.audio_correct_answer_text ?? "",
|
||||||
shortAnswer,
|
shortAnswer,
|
||||||
|
imageUrl: detail.image_url ?? "",
|
||||||
})
|
})
|
||||||
// Open only after the same form shape as create is fully populated (no empty-state flash).
|
// Open only after the same form shape as create is fully populated (no empty-state flash).
|
||||||
setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid })
|
setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid })
|
||||||
|
|
@ -758,7 +756,7 @@ export function HumanLanguagePage() {
|
||||||
points: Number(d.points) || 1,
|
points: Number(d.points) || 1,
|
||||||
tips: d.tips.trim() || undefined,
|
tips: d.tips.trim() || undefined,
|
||||||
explanation: d.explanation.trim() || undefined,
|
explanation: d.explanation.trim() || undefined,
|
||||||
image_url: questionImageUrl.trim() || undefined,
|
image_url: d.imageUrl.trim() || undefined,
|
||||||
voice_prompt: d.voicePrompt.trim() || undefined,
|
voice_prompt: d.voicePrompt.trim() || undefined,
|
||||||
sample_answer_voice_prompt: d.sampleAnswerVoicePrompt.trim() || undefined,
|
sample_answer_voice_prompt: d.sampleAnswerVoicePrompt.trim() || undefined,
|
||||||
audio_correct_answer_text: d.audioCorrectAnswerText.trim() || undefined,
|
audio_correct_answer_text: d.audioCorrectAnswerText.trim() || undefined,
|
||||||
|
|
@ -1917,21 +1915,8 @@ export function HumanLanguagePage() {
|
||||||
}}
|
}}
|
||||||
fieldErrors={questionFieldErrors}
|
fieldErrors={questionFieldErrors}
|
||||||
showFieldErrors={questionSubmitAttempted || questionFormTouched}
|
showFieldErrors={questionSubmitAttempted || questionFormTouched}
|
||||||
|
mediaBusy={savingQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-5 space-y-2 border-t border-grayScale-100 pt-5">
|
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Image URL (Optional)</label>
|
|
||||||
<Input
|
|
||||||
value={questionImageUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuestionFormTouched(true)
|
|
||||||
setQuestionImageUrl(e.target.value)
|
|
||||||
}}
|
|
||||||
placeholder="https://…"
|
|
||||||
type="url"
|
|
||||||
className="h-11 font-mono text-[13px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user