diff --git a/src/components/content-management/DynamicMatchingSlotField.tsx b/src/components/content-management/DynamicMatchingSlotField.tsx new file mode 100644 index 0000000..d342c7e --- /dev/null +++ b/src/components/content-management/DynamicMatchingSlotField.tsx @@ -0,0 +1,241 @@ +import { Plus, Trash2 } from "lucide-react" +import { Button } from "../ui/button" +import { Input } from "../ui/input" +import { + addMatchingInputRow, + addMatchingPair, + defaultMatchingAnswerFromInputs, + MATCHING_MIN_ITEMS, + parseMatchingAnswerSlotValue, + parseMatchingInputsSlotValue, + removeMatchingInputRow, + removeMatchingPair, + serializeMatchingAnswerSlotValue, + serializeMatchingInputsSlotValue, + type MatchingAnswerSlotValue, + type MatchingInputsSlotValue, +} from "../../lib/matchingSlotValue" + +export function DynamicMatchingInputsSlot({ + value, + onChange, + disabled, + slotLabel, +}: { + value: string + onChange: (next: string) => void + disabled: boolean + slotLabel: string +}) { + const parsed = parseMatchingInputsSlotValue(value) + + const updateValue = (next: MatchingInputsSlotValue) => { + onChange(serializeMatchingInputsSlotValue(next)) + } + + const updateSide = ( + side: "left" | "right", + index: number, + text: string, + ) => { + const next = { + left: [...parsed.left], + right: [...parsed.right], + } + next[side][index] = { ...next[side][index], text } + updateValue(next) + } + + const rowCount = Math.max(parsed.left.length, parsed.right.length) + const canRemove = rowCount > MATCHING_MIN_ITEMS + + return ( +
+
+ + +
+ +
+ {Array.from({ length: rowCount }).map((_, index) => ( +
+
+

+ Left {parsed.left[index]?.id ?? `l${index + 1}`} +

+ updateSide("left", index, e.target.value)} + placeholder={`Left item ${index + 1}`} + className="rounded-lg border-grayScale-200 bg-white" + disabled={disabled} + /> +
+
+

+ Right {parsed.right[index]?.id ?? `r${index + 1}`} +

+ updateSide("right", index, e.target.value)} + placeholder={`Right item ${index + 1}`} + className="rounded-lg border-grayScale-200 bg-white" + disabled={disabled} + /> +
+
+ +
+
+ ))} +
+ +

+ Minimum {MATCHING_MIN_ITEMS} rows on each side. Whitespace in text is preserved. +

+
+ ) +} + +export function DynamicMatchingAnswerSlot({ + value, + onChange, + disabled, + slotLabel, + matchingInputs, +}: { + value: string + onChange: (next: string) => void + disabled: boolean + slotLabel: string + matchingInputs: MatchingInputsSlotValue | null +}) { + const parsed = parseMatchingAnswerSlotValue(value, matchingInputs) + + const updateValue = (next: MatchingAnswerSlotValue) => { + onChange(serializeMatchingAnswerSlotValue(next)) + } + + const leftOptions = matchingInputs?.left ?? [] + const rightOptions = matchingInputs?.right ?? [] + + return ( +
+
+ +
+ {matchingInputs ? ( + + ) : null} + +
+
+ + {!matchingInputs ? ( +

+ Fill in matching inputs first so answer pairs can reference left and right ids. +

+ ) : null} + +
+ {parsed.pairs.map((pair, index) => ( +
+ + + + +
+ ))} +
+
+ ) +} diff --git a/src/components/content-management/DynamicSchemaSlotField.tsx b/src/components/content-management/DynamicSchemaSlotField.tsx index 4acd2db..5acd187 100644 --- a/src/components/content-management/DynamicSchemaSlotField.tsx +++ b/src/components/content-management/DynamicSchemaSlotField.tsx @@ -6,7 +6,7 @@ import { type ChangeEvent, type DragEvent, } from "react" -import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react" +import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, Plus, Trash2, X } from "lucide-react" import { toast } from "sonner" import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" @@ -18,6 +18,30 @@ import { cn } from "../../lib/utils" import { ResolvedImage } from "../media/ResolvedImage" import { DynamicTableBuilder } from "./DynamicTableBuilder" import { slotLabel } from "../../lib/schemaSlotLabel" +import { + addMultipleChoiceOption, + parseMultipleChoiceSlotValue, + removeMultipleChoiceOption, + serializeMultipleChoiceSlotValue, + MULTIPLE_CHOICE_MIN_OPTIONS, + type MultipleChoiceSlotValue, +} from "../../lib/multipleChoiceSlotValue" +import { + DynamicMatchingAnswerSlot, + DynamicMatchingInputsSlot, +} from "./DynamicMatchingSlotField" +import { + findMatchingInputsInFieldValues, + type MatchingInputsSlotValue, +} from "../../lib/matchingSlotValue" +import { + DynamicSelectMissingWordsAnswerSlot, + DynamicSelectMissingWordsStimulusSlot, +} from "./DynamicSelectMissingWordsSlotField" +import { + findSelectMissingWordsStimulusInFieldValues, + type SelectMissingWordsStimulusValue, +} from "../../lib/selectMissingWordsSlotValue" const MAX_IMAGE_BYTES = 10 * 1024 * 1024 const MAX_AUDIO_BYTES = 50 * 1024 * 1024 @@ -31,12 +55,49 @@ export interface DynamicSchemaSlotRow { required?: boolean } +function isMultipleChoiceKind(kind: string): boolean { + const u = kind.trim().toUpperCase() + return u === "MULTIPLE_CHOICE" || u === "OPTION" +} + +function isMatchingInputsKind(kind: string): boolean { + return kind.trim().toUpperCase() === "MATCHING_INPUTS" +} + +function isMatchingAnswerKind(kind: string): boolean { + return kind.trim().toUpperCase() === "MATCHING_ANSWER" +} + +function isSelectMissingWordsKind(kind: string): boolean { + return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS" +} + function slotMediaMode( kind: string, -): "image" | "audio" | "pdf" | "table" | "seconds" | "text" { + side: "stimulus" | "response", +): + | "image" + | "audio" + | "pdf" + | "table" + | "seconds" + | "text" + | "multiple_choice" + | "matching_inputs" + | "matching_answer" + | "select_missing_words_stimulus" + | "select_missing_words_answer" { const u = kind.trim().toUpperCase() if (u === "IMAGE") return "image" if (u === "TABLE") return "table" + if (isMultipleChoiceKind(kind)) return "multiple_choice" + if (isMatchingInputsKind(kind)) return "matching_inputs" + if (isMatchingAnswerKind(kind)) return "matching_answer" + if (isSelectMissingWordsKind(kind)) { + return side === "response" + ? "select_missing_words_answer" + : "select_missing_words_stimulus" + } if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf" if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds" if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio" @@ -567,11 +628,114 @@ function DynamicAudioSlot({ ) } +function DynamicMultipleChoiceSlot({ + value, + onChange, + disabled, + slotLabel: label, +}: { + value: string + onChange: (next: string) => void + disabled: boolean + slotLabel: string +}) { + const parsed = parseMultipleChoiceSlotValue(value) + + const updateValue = (next: MultipleChoiceSlotValue) => { + onChange(serializeMultipleChoiceSlotValue(next)) + } + + const updateOptions = (next: MultipleChoiceSlotValue["options"]) => { + updateValue({ options: next }) + } + + const canRemove = parsed.options.length > MULTIPLE_CHOICE_MIN_OPTIONS + + return ( +
+
+ + +
+
+ {parsed.options.map((option, index) => ( +
+ + {option.id} + + { + const options = [...parsed.options] + options[index] = { ...options[index], text: e.target.value } + updateOptions(options) + }} + className="min-w-0 flex-1 rounded-lg border-grayScale-200" + placeholder={`Choice ${index + 1}`} + disabled={disabled} + /> + + +
+ ))} +
+

+ Minimum {MULTIPLE_CHOICE_MIN_OPTIONS} options. Mark one as correct. +

+
+ ) +} + export interface DynamicSchemaSlotFieldProps { row: DynamicSchemaSlotRow value: string onChange: (next: string) => void disabled?: boolean + side: "stimulus" | "response" + allFieldValues?: Record + stimulusSchema?: DynamicSchemaSlotRow[] + responseSchema?: DynamicSchemaSlotRow[] } function DynamicPdfSlot({ @@ -678,9 +842,28 @@ export function DynamicSchemaSlotField({ value, onChange, disabled = false, + side, + allFieldValues, + stimulusSchema = [], + responseSchema = [], }: DynamicSchemaSlotFieldProps) { - const mode = slotMediaMode(row.kind) + const mode = slotMediaMode(row.kind, side) const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}` + const matchingInputs: MatchingInputsSlotValue | null = + mode === "matching_answer" && allFieldValues + ? findMatchingInputsInFieldValues( + allFieldValues, + stimulusSchema, + responseSchema, + ) + : null + const clozeStimulus: SelectMissingWordsStimulusValue | null = + mode === "select_missing_words_answer" && allFieldValues + ? findSelectMissingWordsStimulusInFieldValues( + allFieldValues, + stimulusSchema, + ) + : null if (mode === "table") { return ( @@ -713,6 +896,63 @@ export function DynamicSchemaSlotField({ ) } + if (mode === "multiple_choice") { + return ( + + ) + } + + if (mode === "matching_inputs") { + return ( + + ) + } + + if (mode === "matching_answer") { + return ( + + ) + } + + if (mode === "select_missing_words_stimulus") { + return ( + + ) + } + + if (mode === "select_missing_words_answer") { + return ( + + ) + } + if (mode === "text") { return (
diff --git a/src/components/content-management/DynamicSelectMissingWordsSlotField.tsx b/src/components/content-management/DynamicSelectMissingWordsSlotField.tsx new file mode 100644 index 0000000..c1aac47 --- /dev/null +++ b/src/components/content-management/DynamicSelectMissingWordsSlotField.tsx @@ -0,0 +1,275 @@ +import { Plus, Trash2 } from "lucide-react" +import { Button } from "../ui/button" +import { Input } from "../ui/input" +import { Textarea } from "../ui/textarea" +import { + addBlankSegment, + addTextSegment, + addWordBankItem, + defaultSelectMissingWordsResponseFromStimulus, + parseSelectMissingWordsResponseSlotValue, + parseSelectMissingWordsStimulusSlotValue, + removeSegment, + removeWordBankItem, + SELECT_MISSING_WORDS_MIN_BANK, + serializeSelectMissingWordsResponseSlotValue, + serializeSelectMissingWordsStimulusSlotValue, + setAllowReuse, + syncResponseBlanksWithStimulus, + updateTextSegment, + updateWordBankText, + type SelectMissingWordsStimulusValue, +} from "../../lib/selectMissingWordsSlotValue" + +export function DynamicSelectMissingWordsStimulusSlot({ + value, + onChange, + disabled, + slotLabel, +}: { + value: string + onChange: (next: string) => void + disabled: boolean + slotLabel: string +}) { + const parsed = parseSelectMissingWordsStimulusSlotValue(value) + + const updateValue = (next: SelectMissingWordsStimulusValue) => { + onChange(serializeSelectMissingWordsStimulusSlotValue(next)) + } + + const canRemoveWord = parsed.word_bank.length > SELECT_MISSING_WORDS_MIN_BANK + const canRemoveSegment = parsed.segments.length > 1 + + return ( +
+
+ +
+ + +
+
+ +
+

+ Passage segments +

+ {parsed.segments.map((segment, index) => ( +
+ {segment.type === "text" ? ( +
+

+ Text +

+