diff --git a/src/components/content-management/PracticeQuestionEditorFields.tsx b/src/components/content-management/PracticeQuestionEditorFields.tsx index 56042d6..bcf6779 100644 --- a/src/components/content-management/PracticeQuestionEditorFields.tsx +++ b/src/components/content-management/PracticeQuestionEditorFields.tsx @@ -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 { 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" @@ -11,6 +23,13 @@ export interface PracticeQuestionOptionDraft { 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 @@ -23,6 +42,8 @@ export interface PracticeQuestionEditorValue { sampleAnswerVoicePrompt: string audioCorrectAnswerText: string shortAnswer: string + /** Stored URL or object key; same semantics as Speaking practice editor */ + imageUrl: string } export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue { @@ -43,9 +64,19 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue 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, @@ -93,6 +124,8 @@ export interface PracticeQuestionEditorFieldsProps { onChange: (next: PracticeQuestionEditorValue) => void fieldErrors?: Partial> showFieldErrors?: boolean + /** Disables upload/record controls while parent saves */ + mediaBusy?: boolean } export function PracticeQuestionEditorFields({ @@ -100,7 +133,11 @@ export function PracticeQuestionEditorFields({ onChange, fieldErrors = {}, showFieldErrors = false, + mediaBusy = false, }: PracticeQuestionEditorFieldsProps) { + const valueRef = useRef(value) + valueRef.current = value + const patch = (partial: Partial) => { onChange({ ...value, ...partial }) } @@ -133,216 +170,820 @@ export function PracticeQuestionEditorFields({ onChange({ ...value, options }) } - return ( -
-
- -