import { useCallback, useEffect, useRef, useState, type ChangeEvent, type DragEvent, } from "react" import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react" import { toast } from "sonner" import { uploadAudioFile, uploadImageFile } from "../../api/files.api" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { Input } from "../ui/input" import { Textarea } from "../ui/textarea" import { SpinnerIcon } from "../ui/spinner-icon" import { cn } from "../../lib/utils" import { ResolvedImage } from "../media/ResolvedImage" const MAX_IMAGE_BYTES = 10 * 1024 * 1024 const MAX_AUDIO_BYTES = 50 * 1024 * 1024 const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "gif"]) const AUDIO_EXT = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"]) export interface DynamicSchemaSlotRow { id: string kind: string label?: string required?: boolean } function slotMediaMode(kind: string): "image" | "audio" | "text" { const u = kind.trim().toUpperCase() if (u === "IMAGE") return "image" if (u.startsWith("AUDIO")) return "audio" return "text" } function isHttpUrl(s: string): boolean { return /^https?:\/\//i.test(s.trim()) } function fileLabelFromValue(raw: string): string { const t = raw.trim() if (!t) return "No audio" try { if (isHttpUrl(t)) { const path = new URL(t).pathname.split("/").filter(Boolean) const last = path[path.length - 1] return last ? decodeURIComponent(last) : "Audio clip" } } catch { /* ignore */ } const parts = t.split("/").filter(Boolean) const last = parts[parts.length - 1] return last ? decodeURIComponent(last) : "Audio clip" } function DynamicImageSlot({ value, onChange, disabled, slotLabel, slotMeta, }: { value: string onChange: (next: string) => void disabled: boolean slotLabel: string slotMeta: string }) { const fileInputRef = useRef(null) const valueAtFocusRef = useRef("") const [uploading, setUploading] = useState(false) const [dragActive, setDragActive] = useState(false) const processFile = useCallback( async (file: File) => { if (disabled || uploading) return const ext = file.name.split(".").pop()?.toLowerCase() ?? "" if (!IMAGE_EXT.has(ext)) { toast.error("Unsupported image format", { description: "Use JPG, PNG, WEBP, or GIF.", }) return } if (file.size > MAX_IMAGE_BYTES) { toast.error("Image is too large", { description: "Maximum size is 10 MB." }) return } setUploading(true) try { const res = await uploadImageFile(file) const url = res.data?.data?.url?.trim() if (!url) throw new Error("Upload did not return a URL") onChange(url) toast.success("Image uploaded") } catch (e) { console.error(e) toast.error("Failed to upload image") } finally { setUploading(false) } }, [disabled, uploading, onChange], ) const importUrl = useCallback(async () => { const trimmed = value.trim() if (!trimmed || !isHttpUrl(trimmed)) return if (trimmed === valueAtFocusRef.current) return setUploading(true) try { const res = await uploadImageFile(trimmed) const url = res.data?.data?.url?.trim() if (!url) throw new Error("Import did not return a URL") onChange(url) toast.success("Image URL imported to storage") } catch (e) { console.error(e) toast.error("Could not import image from URL") } finally { setUploading(false) } }, [value, onChange]) const handleFileChange = (e: ChangeEvent) => { const file = e.target.files?.[0] e.target.value = "" if (file) void processFile(file) } const zoneDisabled = disabled || uploading const hasImage = Boolean(value.trim()) return (
{slotMeta}
{ e.preventDefault() if (!zoneDisabled) setDragActive(true) } : undefined } onDragLeave={ hasImage ? (e: DragEvent) => { e.preventDefault() setDragActive(false) } : undefined } onDrop={ hasImage ? (e: DragEvent) => { e.preventDefault() setDragActive(false) if (zoneDisabled) return const file = e.dataTransfer.files?.[0] if (file) void processFile(file) } : undefined } > {hasImage ? (
) : (

Preview appears here after you upload or paste a URL on the right.

)}
onChange(e.target.value)} onFocus={(e) => { valueAtFocusRef.current = e.currentTarget.value.trim() }} onBlur={() => void importUrl()} placeholder="https://…" title="Leave the field after pasting a public URL to import to storage" className="h-12 rounded-xl border-grayScale-200 font-mono text-sm" disabled={disabled || uploading} autoComplete="off" />
) } function WaveformDecor({ active }: { active: boolean }) { const heights = [0.35, 0.55, 0.42, 0.7, 0.5, 0.62, 0.4, 0.58, 0.48, 0.66, 0.44, 0.52, 0.38, 0.6, 0.46, 0.54] return (
{heights.map((h, idx) => (
))}
) } function DynamicAudioSlot({ value, onChange, disabled, slotLabel, slotMeta, }: { value: string onChange: (next: string) => void disabled: boolean slotLabel: string slotMeta: string }) { const fileInputRef = useRef(null) const audioRef = useRef(null) const valueAtFocusRef = useRef("") const [uploading, setUploading] = useState(false) const [dragActive, setDragActive] = useState(false) const [resolvedSrc, setResolvedSrc] = useState("") const [playing, setPlaying] = useState(false) useEffect(() => { let cancelled = false ;(async () => { const raw = value.trim() if (!raw) { setResolvedSrc("") return } try { const url = await resolveMediaPreviewUrl(raw) if (!cancelled) setResolvedSrc(url || raw) } catch { if (!cancelled) setResolvedSrc(raw) } })() return () => { cancelled = true } }, [value]) const processFile = useCallback( async (file: File) => { if (disabled || uploading) return const ext = file.name.split(".").pop()?.toLowerCase() ?? "" if (!AUDIO_EXT.has(ext)) { toast.error("Unsupported audio format") return } if (file.size > MAX_AUDIO_BYTES) { toast.error("Audio file must be 50MB or less") return } setUploading(true) try { const res = await uploadAudioFile(file) const url = res.data?.data?.url?.trim() const objectKey = res.data?.data?.object_key?.trim() const stored = url || objectKey if (!stored) throw new Error("Upload did not return a URL or key") onChange(stored) toast.success("Audio uploaded") } catch (e) { console.error(e) toast.error("Failed to upload audio") } finally { setUploading(false) } }, [disabled, uploading, onChange], ) const importUrl = useCallback(async () => { const trimmed = value.trim() if (!trimmed || !isHttpUrl(trimmed)) return if (trimmed === valueAtFocusRef.current) return setUploading(true) try { const res = await uploadAudioFile(trimmed) const url = res.data?.data?.url?.trim() const objectKey = res.data?.data?.object_key?.trim() const stored = url || objectKey if (!stored) throw new Error("Import did not return a URL or key") onChange(stored) toast.success("Audio URL imported to storage") } catch (e) { console.error(e) toast.error("Could not import audio from URL") } finally { setUploading(false) } }, [value, onChange]) const togglePlay = async () => { const el = audioRef.current if (!el || !resolvedSrc) return if (playing) { el.pause() setPlaying(false) } else { try { await el.play() } catch { toast.error("Could not play audio") } } } const handleFileChange = (e: ChangeEvent) => { const file = e.target.files?.[0] e.target.value = "" if (file) void processFile(file) } const zoneDisabled = disabled || uploading const hasMedia = Boolean(value.trim() && resolvedSrc) return (
{slotMeta}
{hasMedia ? (

{fileLabelFromValue(value)}

) : (

Playback preview appears here after you upload or paste a URL on the right.

)}
onChange(e.target.value)} onFocus={(e) => { valueAtFocusRef.current = e.currentTarget.value.trim() }} onBlur={() => void importUrl()} placeholder="https://…" title="Leave the field after pasting a public URL to import to storage" className="h-12 rounded-xl border-grayScale-200 font-mono text-sm" disabled={disabled || uploading} autoComplete="off" />
) } export interface DynamicSchemaSlotFieldProps { row: DynamicSchemaSlotRow value: string onChange: (next: string) => void disabled?: boolean } export function DynamicSchemaSlotField({ row, value, onChange, disabled = false, }: DynamicSchemaSlotFieldProps) { const mode = slotMediaMode(row.kind) const baseLabel = row.label?.trim() || (mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind) const slotLabel = `${baseLabel}${row.required ? " *" : ""}` const slotMeta = `${row.id} · ${row.kind}` if (mode === "text") { return (
{slotMeta}