diff --git a/package-lock.json b/package-lock.json index c9aee8ca..059e1355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@beam-australia/react-env": "^3.1.1", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@firebase/util": "^1.9.7", "@headlessui/react": "^2.1.2", @@ -47,6 +48,7 @@ "formidable-serverless": "^1.1.1", "framer-motion": "^9.0.2", "howler": "^2.2.4", + "immer": "^10.1.1", "iron-session": "^6.3.1", "lodash": "^4.17.21", "moment": "^2.29.4", @@ -463,6 +465,19 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/sortable": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", @@ -7302,6 +7317,15 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -12008,6 +12032,15 @@ "tslib": "^2.0.0" } }, + "@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "requires": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + } + }, "@dnd-kit/sortable": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", @@ -17337,6 +17370,11 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, + "immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==" + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", diff --git a/package.json b/package.json index 8903a0c9..6e705f2a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@beam-australia/react-env": "^3.1.1", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@firebase/util": "^1.9.7", "@headlessui/react": "^2.1.2", @@ -49,6 +50,7 @@ "formidable-serverless": "^1.1.1", "framer-motion": "^9.0.2", "howler": "^2.2.4", + "immer": "^10.1.1", "iron-session": "^6.3.1", "lodash": "^4.17.21", "moment": "^2.29.4", diff --git a/public/microsoft-word-icon.png b/public/microsoft-word-icon.png new file mode 100644 index 00000000..879ae1e9 Binary files /dev/null and b/public/microsoft-word-icon.png differ diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 61054e6b..672c5ded 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -2,29 +2,42 @@ import React, { useState, ReactNode, useRef, useEffect } from 'react'; import { animated, useSpring } from '@react-spring/web'; interface DropdownProps { - title: ReactNode; + title?: ReactNode; open?: boolean; + setIsOpen?: React.Dispatch> | ((isOpen: boolean) => void); className?: string; contentWrapperClassName?: string; + titleClassName?: string; bottomPadding?: number; + disabled?: boolean, + wrapperClassName?: string; + customTitle?: ReactNode; children: ReactNode; } const Dropdown: React.FC = ({ title, open = false, + titleClassName = "", + setIsOpen: externalSetIsOpen, className = "w-full text-left font-semibold flex justify-between items-center p-4", contentWrapperClassName = "px-6", bottomPadding = 12, + disabled = false, + customTitle = undefined, + wrapperClassName, children }) => { - const [isOpen, setIsOpen] = useState(open); + const [internalIsOpen, setInternalIsOpen] = useState(open); + const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen; + const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen; + const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState(0); useEffect(() => { let resizeObserver: ResizeObserver | null = null; - + if (contentRef.current) { resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { @@ -38,10 +51,10 @@ const Dropdown: React.FC = ({ } } }); - + resizeObserver.observe(contentRef.current); } - + return () => { if (resizeObserver) { resizeObserver.disconnect(); @@ -56,28 +69,35 @@ const Dropdown: React.FC = ({ }); return ( - <> +
-
+
{children}
- +
); }; diff --git a/src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx b/src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx new file mode 100644 index 00000000..19f4e36c --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx @@ -0,0 +1,129 @@ +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import clsx from "clsx"; +import { MdClose, MdDelete, MdDragIndicator } from "react-icons/md"; +import { CSS } from "@dnd-kit/utilities"; +import { useEffect, useState } from "react"; +import ConfirmDeleteBtn from "../../Shared/ConfirmDeleteBtn"; + +interface BlankProps { + id: number; + module: string; + variant: "text" | "bank"; + isSelected?: boolean; + isDragging?: boolean; + onSelect?: (id: number) => void; + onRemove?: (id: number) => void; + disabled?: boolean; +} + +export const Blank: React.FC = ({ + id, + module, + variant, + isSelected, + isDragging, + onSelect, + onRemove, + disabled, +}) => { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: `${variant}-blank-${id}`, + disabled: disabled || variant !== "text", + }); + + const style = transform ? { + transform: CSS.Translate.toString(transform), + transition: 'none', + zIndex: 999, + position: 'relative' as const, + touchAction: 'none', + } : { + transition: 'transform 0.2s cubic-bezier(0.25, 1, 0.5, 1)', + touchAction: 'none', + position: 'relative' as const, + }; + + const handleClick = (e: React.MouseEvent) => { + if (variant === "bank" && !disabled && onSelect) { + onSelect(id); + } + }; + + const dragProps = variant === "text" ? { + ...attributes, + ...listeners, + } : {}; + + return ( +
+ {variant === "text" && ( + + {isSelected ? + : + + } + + )} + + {id} + + + {onRemove && !isDragging && ( + onRemove(id)} + size="md" + position="top-right" + className="-translate-y-2 translate-x-1.5" + /> + )} +
+ ); +}; + +export const DropZone: React.FC<{ index: number, module: string; }> = ({ index, module }) => { + const { setNodeRef, isOver } = useDroppable({ + id: `drop-${index}`, + }); + + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx b/src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx new file mode 100644 index 00000000..5f3862a8 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx @@ -0,0 +1,247 @@ +import { toast } from "react-toastify"; + +type TextToken = { + type: 'text'; + content: string; + isWhitespace: boolean; + isLineBreak?: boolean; +}; + +type BlankToken = { + type: 'blank'; + id: number; +}; + +type Token = TextToken | BlankToken; + +export type BlankState = { + id: number; + position: number; +}; + + +export const getTextSegments = (text: string): Token[] => { + const tokens: Token[] = []; + let lastIndex = 0; + const regex = /{{(\d+)}}/g; + let match; + + const addTextTokens = (text: string) => { + // Split by newlines first + const lines = text.replaceAll("\\n",'\n').split(/(\n)/); + + lines.forEach((line, i) => { + if (line === '\n') { + tokens.push({ + type: 'text', + content: '
', + isWhitespace: false, + isLineBreak: true + }); + return; + } + + const normalizedText = line.replace(/\s+/g, ' '); + if (normalizedText) { + const parts = normalizedText.split(/(\s)/); + parts.forEach(part => { + if (part) { + tokens.push({ + type: 'text', + content: part, + isWhitespace: /^\s+$/.test(part) + }); + } + }); + } + }); + }; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + addTextTokens(text.slice(lastIndex, match.index)); + } + tokens.push({ + type: 'blank', + id: parseInt(match[1]) + }); + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + addTextTokens(text.slice(lastIndex)); + } + + return tokens; +} + +export const reconstructTextFromTokens = (tokens: Token[]): string => { + return tokens.map(token => { + if (token.type === 'blank') { + return `{{${token.id}}}`; + } + if (token.type === 'text' && token.isLineBreak) { + return '\n'; + } + return token.content; + }).join(''); +} + + +export type BlanksState = { + text: string; + blanks: BlankState[]; + selectedBlankId: number | null; + draggedItemId: string | null; + textMode: boolean; + setEditing: React.Dispatch>; +}; + +export type BlanksAction = + | { type: "SET_TEXT"; payload: string } + | { type: "SET_BLANKS"; payload: BlankState[] } + | { type: "ADD_BLANK" } + | { type: "REMOVE_BLANK"; payload: number } + | { type: "SELECT_BLANK"; payload: number | null } + | { type: "SET_DRAGGED_ITEM"; payload: string | null } + | { type: "MOVE_BLANK"; payload: { blankId: number; newPosition: number } } + | { type: "TOGGLE_EDIT_MODE" } + | { type: "RESET", payload: { text: string } }; + + +export const blanksReducer = (state: BlanksState, action: BlanksAction): BlanksState => { + switch (action.type) { + case "SET_TEXT": { + return { + ...state, + text: action.payload, + }; + } + case "SET_BLANKS": { + return { + ...state, + blanks: action.payload, + }; + } + case "ADD_BLANK": + state.setEditing(true); + const newBlankId = Math.max(...state.blanks.map(b => b.id), 0) + 1; + const newBlanks = [ + ...state.blanks, + { id: newBlankId, position: state.blanks.length } + ]; + const newText = state.text + ` {{${newBlankId}}}`; + + return { + ...state, + blanks: newBlanks, + text: newText + }; + + case "REMOVE_BLANK": { + if (state.blanks.length === 1) { + toast.error("There needs to be at least 1 blank!"); + break; + } + state.setEditing(true); + const blanksToKeep = state.blanks.filter(b => b.id !== action.payload); + const updatedBlanks = blanksToKeep.map((blank, index) => ({ + ...blank, + position: index + })); + + const tokens = getTextSegments(state.text).filter( + token => !(token.type === 'blank' && token.id === action.payload) + ); + + const newText = reconstructTextFromTokens(tokens); + + return { + ...state, + blanks: updatedBlanks, + text: newText, + selectedBlankId: state.selectedBlankId === action.payload ? null : state.selectedBlankId + }; + } + + case "MOVE_BLANK": { + state.setEditing(true); + const { blankId, newPosition } = action.payload; + const tokens = getTextSegments(state.text); + + // Find the current position of the blank + const currentPosition = tokens.findIndex( + token => token.type === 'blank' && token.id === blankId + ); + + if (currentPosition === -1) return state; + + // Remove the blank and its surrounding whitespace + const blankToken = tokens[currentPosition]; + tokens.splice(currentPosition, 1); + + // When inserting at new position, ensure there's whitespace around the blank + let insertPosition = newPosition; + const prevToken = tokens[insertPosition - 1]; + const nextToken = tokens[insertPosition]; + + // Insert space before if needed + if (!prevToken || (prevToken.type === 'text' && !prevToken.isWhitespace)) { + tokens.splice(insertPosition, 0, { + type: 'text', + content: ' ', + isWhitespace: true + }); + insertPosition++; + } + + // Insert the blank + tokens.splice(insertPosition, 0, blankToken); + insertPosition++; + + // Insert space after if needed + if (!nextToken || (nextToken.type === 'text' && !nextToken.isWhitespace)) { + tokens.splice(insertPosition, 0, { + type: 'text', + content: ' ', + isWhitespace: true + }); + } + + // Reconstruct the text + const newText = reconstructTextFromTokens(tokens); + + // Update blank positions + const updatedBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + + return { + ...state, + text: newText, + blanks: updatedBlanks + }; + } + case "SELECT_BLANK": + return { ...state, selectedBlankId: action.payload }; + case "SET_DRAGGED_ITEM": + state.setEditing(true); + return { ...state, draggedItemId: action.payload }; + case "TOGGLE_EDIT_MODE": + return { ...state, textMode: !state.textMode }; + + case "RESET": + return { + text: action.payload.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing: state.setEditing + }; + } + return state; +}; diff --git a/src/components/ExamEditor/Exercises/Blanks/Letters/FillBlanksWord.tsx b/src/components/ExamEditor/Exercises/Blanks/Letters/FillBlanksWord.tsx new file mode 100644 index 00000000..0919cf50 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/Letters/FillBlanksWord.tsx @@ -0,0 +1,62 @@ +import { MdDelete } from "react-icons/md"; + +interface Props { + letter: string; + word: string; + isSelected: boolean; + isUsed: boolean; + onClick: () => void; + onRemove?: () => void; + onEdit?: (newWord: string) => void; + isEditMode?: boolean; +} + +const FillBlanksWord: React.FC = ({ + letter, + word, + isSelected, + isUsed, + onClick, + onRemove, + onEdit, + isEditMode + }) => { + return ( +
+ {isEditMode ? ( +
+ {letter} + onEdit?.(e.target.value)} + className="w-full min-w-0 focus:outline-none" + /> +
+ ) : ( + + )} + {isEditMode && onRemove && ( + + )} +
+ ); + }; +export default FillBlanksWord; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx new file mode 100644 index 00000000..2086380b --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx @@ -0,0 +1,301 @@ +import { FillBlanksExercise, ReadingPart } from "@/interfaces/exam"; +import { useEffect, useReducer, useState } from "react"; +import BlanksEditor from ".."; +import { Card, CardContent } from "@/components/ui/card"; +import { MdEdit, MdEditOff } from "react-icons/md"; +import FillBlanksWord from "./FillBlanksWord"; +import { FaPlus } from "react-icons/fa"; +import useExamEditorStore from "@/stores/examEditor"; +import { blanksReducer, BlankState, getTextSegments } from "../FillBlanksReducer"; +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { AlertItem } from "../../Shared/Alert"; +import validateBlanks from "../validateBlanks"; +import { toast } from "react-toastify"; +import setEditingAlert from "../../Shared/setEditingAlert"; + +interface Word { + letter: string; + word: string; +} + +const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + + const [alerts, setAlerts] = useState([]); + + const [local, setLocal] = useState(exercise); + const [selectedBlankId, setSelectedBlankId] = useState(null); + const [answers, setAnswers] = useState>( + new Map(exercise.solutions.map(({ id, solution }) => [id, solution])) + ); + const [isEditMode, setIsEditMode] = useState(false); + const [newWord, setNewWord] = useState(''); + + const [editing, setEditing] = useState(false); + + const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { + text: exercise.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing, + }); + + const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + editing, + setEditing, + onSave: () => { + if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const updatedExercise = { + ...local, + text: blanksState.text, + solutions: Array.from(answers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setSelectedBlankId(null); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + setIsEditMode(false); + setNewWord(''); + setLocal(exercise); + + blanksDispatcher({ type: "RESET", payload: { text: exercise.text } }); + blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" }); + + const tokens = getTextSegments(exercise.text || ""); + const initialBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); + + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + } + }); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + } + }, [exercise, editing]); + + const handleWordSelect = (word: string) => { + if (!selectedBlankId) return; + + if (!editing) setEditing(true); + + const newAnswers = new Map(answers); + newAnswers.set(selectedBlankId, word); + + setAnswers(newAnswers); + + setLocal(prev => ({ + ...prev, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + })); + }; + + const handleAddWord = () => { + const word = newWord.trim(); + if (!word) return; + + setLocal(prev => { + const nextLetter = String.fromCharCode(65 + prev.words.length); + return { + ...prev, + words: [...prev.words, { letter: nextLetter, word }] + }; + }); + setNewWord(''); + }; + + const handleRemoveWord = (index: number) => { + if (!editing) setEditing(true); + + if (answers.size === 1) { + toast.error("There needs to be at least 1 word!"); + return; + } + + setLocal(prev => { + const newWords = prev.words.filter((_, i) => i !== index) as Word[]; + const removedWord = prev.words[index] as Word; + + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (answer === removedWord.word) { + newAnswers.delete(blankId); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + const isWordUsed = (word: string): boolean => { + if (local.allowRepetition) return false; + return Array.from(answers.values()).includes(word); + }; + + const handleEditWord = (index: number, newWord: string) => { + if (!editing) setEditing(true); + + + setLocal(prev => { + const newWords = [...prev.words] as Word[]; + const oldWord = newWords[index].word; + newWords[index] = { ...newWords[index], word: newWord }; + + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (answer === oldWord) { + newAnswers.set(blankId, newWord); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + useEffect(() => { + validateBlanks(blanksState.blanks, answers, alerts, setAlerts); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers, blanksState.blanks, blanksState.textMode]) + + useEffect(()=> { + setEditingAlert(editing, setAlerts); + }, [editing]) + + return ( +
+ setSelectedBlankId(blankId?.toString() || null)} + onSave={handleSave} + onDiscard={handleDiscard} + onDelete={modeHandle} + setEditing={setEditing} + > + <> + {!blanksState.textMode && + +
+
Word Bank
+ +
+ +
+ {(local.words as Word[]).map((wordItem, index) => ( + handleWordSelect(wordItem.word)} + onRemove={isEditMode ? () => handleRemoveWord(index) : undefined} + onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined} + isEditMode={isEditMode} + /> + ))} +
+ + {isEditMode && ( +
+ setNewWord(e.target.value)} + placeholder="Enter new word" + className="flex-1 px-3 py-2 border border-r-0 rounded-l-md focus:outline-none" + name="" + /> + +
+ + )} +
+
+ } + +
+
+ ); +}; + +export default FillBlanksLetters; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/MCOption.tsx b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/MCOption.tsx new file mode 100644 index 00000000..6b5595c0 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/MCOption.tsx @@ -0,0 +1,79 @@ + +import { MdDelete, } from "react-icons/md"; +import clsx from "clsx"; + +interface MCOptionProps { + id: string; + options: { + A: string; + B: string; + C: string; + D: string; + }; + selectedOption?: string; + onSelect: (option: string) => void; + isEditMode?: boolean; + onEdit?: (key: 'A' | 'B' | 'C' | 'D', value: string) => void; + onRemove?: () => void; +} + +const MCOption: React.FC = ({ + id, + options, + selectedOption, + onSelect, + isEditMode, + onEdit, + onRemove +}) => { + const optionKeys = ['A', 'B', 'C', 'D'] as const; + + return ( +
+
+ Question {id} + {isEditMode && onRemove && ( + + )} +
+
+ {optionKeys.map((key) => ( +
+ {isEditMode ? ( +
+ {key} + onEdit?.(key, e.target.value)} + className="w-full focus:outline-none" + /> +
+ ) : ( + + )} +
+ ))} +
+
+ ); +}; + +export default MCOption; diff --git a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx new file mode 100644 index 00000000..f0908fea --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx @@ -0,0 +1,284 @@ +import { FillBlanksExercise, FillBlanksMCOption, ReadingPart } from "@/interfaces/exam"; +import { useEffect, useReducer, useState } from "react"; +import BlanksEditor from ".."; +import { Card, CardContent } from "@/components/ui/card"; +import useExamEditorStore from "@/stores/examEditor"; +import { blanksReducer, BlankState, getTextSegments } from "../FillBlanksReducer"; +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { AlertItem } from "../../Shared/Alert"; +import validateBlanks from "../validateBlanks"; +import { toast } from "react-toastify"; +import setEditingAlert from "../../Shared/setEditingAlert"; +import { MdEdit, MdEditOff } from "react-icons/md"; +import MCOption from "./MCOption"; + + +const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + const [alerts, setAlerts] = useState([]); + const [local, setLocal] = useState(exercise); + const [selectedBlankId, setSelectedBlankId] = useState(null); + + const [answers, setAnswers] = useState>(() => { + return new Map( + exercise.solutions.map(({ id, solution }) => [ + id.toString(), + solution + ]) + ); + }); + + const [isEditMode, setIsEditMode] = useState(false); + const [editing, setEditing] = useState(false); + + const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { + text: exercise.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing, + }); + + const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + editing, + setEditing, + onSave: () => { + if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const updatedExercise = { + ...local, + text: blanksState.text, + solutions: Array.from(answers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setSelectedBlankId(null); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + setIsEditMode(false); + setLocal(exercise); + + blanksDispatcher({ type: "RESET", payload: { text: exercise.text } }); + blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" }); + + const tokens = getTextSegments(exercise.text || ""); + const initialBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + } + }); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))); + } + }, [exercise, editing]); + + const handleOptionSelect = (option: string) => { + if (!selectedBlankId) return; + if (!editing) setEditing(true); + + const newAnswers = new Map(answers); + newAnswers.set(selectedBlankId, option); + + setAnswers(newAnswers); + + setLocal(prev => ({ + ...prev, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + })); + }; + + const handleEditOption = (mcOptionIndex: number, key: keyof FillBlanksMCOption['options'], value: string) => { + if (!editing) setEditing(true); + + setLocal(prev => { + const newWords = [...prev.words] as FillBlanksMCOption[]; + const mcOption = newWords[mcOptionIndex] as FillBlanksMCOption; + + const newOptions = { ...mcOption.options, [key]: value }; + newWords[mcOptionIndex] = { ...mcOption, options: newOptions }; + + const oldValue = (mcOption.options as any)[key]; + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (answer === oldValue) { + newAnswers.set(blankId, value); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + const handleRemoveOption = (index: number) => { + if (!editing) setEditing(true); + + if (answers.size === 1) { + toast.error("There needs to be at least 1 question!"); + return; + } + + setLocal(prev => { + const newWords = prev.words.filter((_, i) => i !== index) as FillBlanksMCOption[]; + const removedOption = prev.words[index] as FillBlanksMCOption; + const removedValues = Object.values(removedOption.options); + const newAnswers = new Map(answers); + for (const [blankId, answer] of newAnswers.entries()) { + if (removedValues.includes(answer)) { + newAnswers.delete(blankId); + } + } + setAnswers(newAnswers); + + return { + ...prev, + words: newWords, + solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + id, + solution + })) + }; + }); + }; + + useEffect(() => { + validateBlanks(blanksState.blanks, answers, alerts, setAlerts); + }, [answers, blanksState.blanks, blanksState.textMode]); + + useEffect(() => { + setEditingAlert(editing, setAlerts); + }, [editing]); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + setAnswers(new Map( + exercise.solutions.map(({ id, solution }) => [ + id.toString(), + solution + ]) + )); + } + }, [exercise, editing]); + + useEffect(() => { + setAnswers(new Map( + exercise.solutions.map(({ id, solution }) => [ + id.toString(), + solution + ]) + )); + }, []); + + return ( +
+ setSelectedBlankId(blankId?.toString() || null)} + onSave={handleSave} + onDiscard={handleDiscard} + onDelete={modeHandle} + setEditing={setEditing} + > + {!blanksState.textMode && selectedBlankId && ( + + +
+
Multiple Choice Options
+ +
+ + {(local.words as FillBlanksMCOption[]).map((mcOption) => { + if (mcOption.id.toString() !== selectedBlankId) return null; + + return ( + handleOptionSelect(option)} + isEditMode={isEditMode} + onEdit={(key, value) => handleEditOption( + (local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id), + key as "A" | "B" | "C" | "D", + value + )} + onRemove={() => handleRemoveOption( + (local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id) + )} + /> + ); + })} +
+
+ )} +
+
+ ); +}; + +export default FillBlanksMC; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/AlternativeSolutions.tsx b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/AlternativeSolutions.tsx new file mode 100644 index 00000000..f387a3c3 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/AlternativeSolutions.tsx @@ -0,0 +1,47 @@ +import { MdDelete, MdAdd } from "react-icons/md"; + +interface AlternativeSolutionProps { + solutions: string[]; + onAdd: () => void; + onRemove: (index: number) => void; + onEdit: (index: number, value: string) => void; +} + +const AlternativeSolutions: React.FC = ({ + solutions, + onAdd, + onRemove, + onEdit, +}) => { + return ( +
+ {solutions.map((solution, index) => ( +
+ onEdit(index, e.target.value)} + className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" + placeholder={`Solution ${index + 1}`} + /> + +
+ ))} + +
+ ); +}; + +export default AlternativeSolutions; diff --git a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx new file mode 100644 index 00000000..58507d72 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx @@ -0,0 +1,189 @@ +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { Card, CardContent } from "@/components/ui/card"; +import { WriteBlanksExercise, ReadingPart } from "@/interfaces/exam"; +import useExamEditorStore from "@/stores/examEditor"; +import { useState, useReducer, useEffect } from "react"; +import { toast } from "react-toastify"; +import BlanksEditor from ".."; +import { AlertItem } from "../../Shared/Alert"; +import setEditingAlert from "../../Shared/setEditingAlert"; +import { blanksReducer } from "../FillBlanksReducer"; +import { validateWriteBlanks } from "./validation"; +import AlternativeSolutions from "./AlternativeSolutions"; +import clsx from "clsx"; + +const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + + const [alerts, setAlerts] = useState([]); + const [local, setLocal] = useState(exercise); + const [selectedBlankId, setSelectedBlankId] = useState(null); + const [editing, setEditing] = useState(false); + + const [blanksState, blanksDispatcher] = useReducer(blanksReducer, { + text: exercise.text || "", + blanks: [], + selectedBlankId: null, + draggedItemId: null, + textMode: false, + setEditing, + }); + + const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + editing, + setEditing, + onSave: () => { + if (!validateWriteBlanks(local.solutions, local.maxWords, setAlerts)) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const updatedExercise = { + ...local, + text: blanksState.text, + }; + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + }, + onDiscard: () => { + setSelectedBlankId(null); + setLocal(exercise); + blanksDispatcher({ type: "RESET", payload: { text: exercise.text } }); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + } + }); + + useEffect(() => { + if (!editing) { + setLocal(exercise); + } + }, [exercise, editing]); + + const handleAddSolution = (blankId: string) => { + if (!editing) setEditing(true); + setLocal(prev => ({ + ...prev, + solutions: prev.solutions.map(s => + s.id === blankId + ? { ...s, solution: [...s.solution, ""] } + : s + ) + })); + }; + + const handleRemoveSolution = (blankId: string, index: number) => { + if (!editing) setEditing(true); + + const solutions = local.solutions.find(s => s.id === blankId); + if (solutions && solutions.solution.length <= 1) { + toast.error("Each blank must have at least one solution!"); + return; + } + + setLocal(prev => ({ + ...prev, + solutions: prev.solutions.map(s => + s.id === blankId + ? { ...s, solution: s.solution.filter((_, i) => i !== index) } + : s + ) + })); + }; + + const handleEditSolution = (blankId: string, index: number, value: string) => { + if (!editing) setEditing(true); + + setLocal(prev => ({ + ...prev, + solutions: prev.solutions.map(s => + s.id === blankId + ? { + ...s, + solution: s.solution.map((sol, i) => i === index ? value : sol) + } + : s + ) + })); + }; + + useEffect(() => { + validateWriteBlanks(local.solutions, local.maxWords, setAlerts); + }, [local.solutions, local.maxWords]); + + useEffect(() => { + setEditingAlert(editing, setAlerts); + }, [editing]); + + return ( +
+ setSelectedBlankId(blankId?.toString() || null)} + onSave={handleSave} + onDiscard={handleDiscard} + onDelete={modeHandle} + setEditing={setEditing} + > + {!blanksState.textMode && ( + + +
+ + {selectedBlankId + ? `Solutions for Blank ${selectedBlankId}` + : "Click a blank to edit its solutions"} + + {selectedBlankId && ( + + Max words per solution: {local.maxWords} + + )} +
+ +
+ {selectedBlankId && ( + s.id === selectedBlankId)?.solution || []} + onAdd={() => handleAddSolution(selectedBlankId)} + onRemove={(index: number) => handleRemoveSolution(selectedBlankId, index)} + onEdit={(index: number, value: string) => handleEditSolution(selectedBlankId, index, value)} + /> + )} +
+
+
+ )} +
+
+ ); +}; + +export default WriteBlanksFill; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/validation.ts b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/validation.ts new file mode 100644 index 00000000..b23f0b4c --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/validation.ts @@ -0,0 +1,59 @@ +import { AlertItem } from "../../Shared/Alert"; +import { BlankState } from "../FillBlanksReducer"; + + +export const validateWriteBlanks = ( + solutions: { id: string; solution: string[] }[], + maxWords: number, + setAlerts: React.Dispatch> +): boolean => { + let isValid = true; + + const emptySolutions = solutions.flatMap(s => + s.solution.map((sol, index) => ({ + blankId: s.id, + solutionIndex: index, + isEmpty: !sol.trim() + })) + ).filter(({ isEmpty }) => isEmpty); + + if (emptySolutions.length > 0) { + isValid = false; + setAlerts(prev => { + const filtered = prev.filter(a => !a.tag?.startsWith('empty-solution')); + return [...filtered, ...emptySolutions.map(({ blankId, solutionIndex }) => ({ + variant: "error" as const, + tag: `empty-solution-${blankId}-${solutionIndex}`, + description: `Solution ${solutionIndex + 1} for blank ${blankId} cannot be empty` + }))]; + }); + } else { + setAlerts(prev => prev.filter(a => !a.tag?.startsWith('empty-solution'))); + } + + if (maxWords > 0) { + const invalidWordCount = solutions.flatMap(s => + s.solution.map((sol, index) => ({ + blankId: s.id, + solutionIndex: index, + wordCount: sol.trim().split(/\s+/).length + })) + ).filter(({ wordCount }) => wordCount > maxWords); + + if (invalidWordCount.length > 0) { + isValid = false; + setAlerts(prev => { + const filtered = prev.filter(a => !a.tag?.startsWith('word-count')); + return [...filtered, ...invalidWordCount.map(({ blankId, solutionIndex, wordCount }) => ({ + variant: "error" as const, + tag: `word-count-${blankId}-${solutionIndex}`, + description: `Solution ${solutionIndex + 1} for blank ${blankId} exceeds maximum of ${maxWords} words (current: ${wordCount} words)` + }))]; + }); + } else { + setAlerts(prev => prev.filter(a => !a.tag?.startsWith('word-count'))); + } + } + + return isValid; +}; diff --git a/src/components/ExamEditor/Exercises/Blanks/index.tsx b/src/components/ExamEditor/Exercises/Blanks/index.tsx new file mode 100644 index 00000000..553180ac --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/index.tsx @@ -0,0 +1,246 @@ +import React, { useCallback, useMemo, useReducer, useEffect, ReactNode } from "react"; +import { + DndContext, + DragEndEvent, + DragStartEvent, + MeasuringStrategy, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + restrictToWindowEdges, + snapCenterToCursor, +} from "@dnd-kit/modifiers"; +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import Header from "../../Shared/Header"; +import Alert, { AlertItem } from "../Shared/Alert"; +import clsx from "clsx"; +import { Card, CardContent } from "@/components/ui/card"; +import { Blank, DropZone } from "./DragNDrop"; +import { getTextSegments, BlankState, BlanksState, BlanksAction } from "./FillBlanksReducer"; + + +interface Props { + title?: string; + initialText: string; + description: string; + state: BlanksState; + module: string; + editing: boolean; + showBlankBank: boolean; + alerts: AlertItem[]; + setEditing: React.Dispatch>; + blanksDispatcher: React.Dispatch + onBlankSelect?: (blankId: number | null) => void; + onSave: () => void; + onDiscard: () => void; + onDelete: () => void; + children: ReactNode; +} + +const BlanksEditor: React.FC = ({ + title = "Fill Blanks", + initialText, + description, + state, + editing, + module, + children, + showBlankBank = true, + alerts, + blanksDispatcher, + onBlankSelect, + onSave, + onDiscard, + onDelete, + setEditing +}) => { + + useEffect(() => { + const tokens = getTextSegments(initialText); + const initialBlanks = tokens.reduce((acc, token, idx) => { + if (token.type === 'blank') { + acc.push({ id: token.id, position: idx }); + } + return acc; + }, [] as BlankState[]); + + blanksDispatcher({ type: "SET_TEXT", payload: initialText }); + blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); + }, [initialText, blanksDispatcher]); + + const tokens = useMemo(() => { + return getTextSegments(state.text || ""); + }, [state.text]); + + const handleDragStart = useCallback((event: DragStartEvent) => { + blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: event.active.id.toString() }); + }, [blanksDispatcher]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return; + + const blankId = parseInt(active.id.toString().split("-").pop() || ""); + const dropIndex = parseInt(over.id.toString().split("-")[1]); + + blanksDispatcher({ + type: "MOVE_BLANK", + payload: { blankId, newPosition: dropIndex }, + }); + + blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: null }); + }, + [blanksDispatcher] + ); + + const handleTextChange = useCallback( + (newText: string) => { + const processedText = newText.replace(/\[(\d+)\]/g, "{{$1}}"); + blanksDispatcher({ type: "SET_TEXT", payload: processedText }); + }, + [blanksDispatcher] + ); + + useEffect(() => { + if (onBlankSelect !== undefined) onBlankSelect(state.selectedBlankId); + }, [state.selectedBlankId, onBlankSelect]); + + const handleBlankSelect = (blankId: number) => { + blanksDispatcher({ + type: "SELECT_BLANK", + payload: blankId === state.selectedBlankId ? null : blankId, + }); + }; + + const handleBlankRemove = useCallback((blankId: number) => { + blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId }); + }, [blanksDispatcher]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 4, + tolerance: 5, + }, + }) + ); + + const modifiers = [snapCenterToCursor, restrictToWindowEdges]; + + const measuring = { + droppable: { + strategy: MeasuringStrategy.Always, + }, + }; + + return ( +
+
+ {alerts.length > 0 && } + + + + + + + + + + + {state.textMode ? ( + { handleTextChange(text); if (!editing) setEditing(true) } } + className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md" + placeholder="Enter text here. Use [1], [2], etc. for blanks..." + /> + ) : ( +
+ {tokens.map((token, index) => { + const isWordToken = token.type === 'text' && !token.isWhitespace; + const showDropZone = isWordToken || token.type === 'blank'; + + return ( + + {showDropZone && } + {token.type === 'blank' ? ( + + ) : token.isLineBreak ? ( +
+ ) : ( + {token.content} + )} +
+ ); + })} + {tokens.length > 0 && + tokens[tokens.length - 1].type === 'text' && ( + + )} +
+ )} +
+
+ + + {(!state.textMode && showBlankBank) && ( + + + {state.blanks.map(blank => ( + + ))} + + + )} + {children} +
+
+ ); +} + +export default BlanksEditor; diff --git a/src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts b/src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts new file mode 100644 index 00000000..9c0ff825 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts @@ -0,0 +1,38 @@ +import { AlertItem } from "../Shared/Alert"; +import { BlankState } from "./FillBlanksReducer"; + + +const validateBlanks = ( + blanks: BlankState[], + answers: Map, + alerts: AlertItem[], + setAlerts: React.Dispatch>, + save: boolean = false, +): boolean => { + const unfilledBlanks = blanks.filter(blank => !answers.has(blank.id.toString())); + const filteredAlerts = alerts.filter(alert => alert.tag !== "unfilled-blanks"); + + if (unfilledBlanks.length > 0) { + if (!save && !filteredAlerts.some(alert => alert.tag === "editing")) { + filteredAlerts.push({ + variant: "info", + description: "You have unsaved changes. Don't forget to save your work!", + tag: "editing" + }); + } + setAlerts([ + ...filteredAlerts, + { + variant: "error", + tag: "unfilled-blanks", + description: `${unfilledBlanks.length} blank${unfilledBlanks.length > 1 ? 's' : ''} ${unfilledBlanks.length > 1 ? 'are' : 'is'} missing a word (blanks: ${unfilledBlanks.map(blank => blank.id).join(", ")})` + } + ]); + return false; + } else if (filteredAlerts.length !== alerts.length) { + setAlerts(filteredAlerts); + } + return true; +}; + +export default validateBlanks; diff --git a/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx new file mode 100644 index 00000000..cdee19cc --- /dev/null +++ b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx @@ -0,0 +1,45 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { MatchSentenceExerciseOption } from "@/interfaces/exam"; +import { MdVisibilityOff } from "react-icons/md"; + +interface Props { + showReference: boolean; + selectedReference: string | null; + options: MatchSentenceExerciseOption[]; + headings: boolean; + setShowReference: React.Dispatch>; +} + +const ReferenceViewer: React.FC = ({ showReference, selectedReference, options, setShowReference, headings = true}) => ( +
+
+
+

{headings ? "Reference Paragraphs" : "Authors"}

+ +
+
+
+ {options.map((option) => ( + + + {headings ? "Paragraph" : "Author" } {option.id} + + +

{option.sentence}

+
+
+ ))} +
+
+
+
+); + +export default ReferenceViewer; diff --git a/src/components/ExamEditor/Exercises/MatchSentences/index.tsx b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx new file mode 100644 index 00000000..a81aa89b --- /dev/null +++ b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx @@ -0,0 +1,230 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { + MdAdd, + MdVisibility, + MdVisibilityOff +} from 'react-icons/md'; +import { MatchSentencesExercise, ReadingPart } from '@/interfaces/exam'; +import Alert, { AlertItem } from '../Shared/Alert'; +import ReferenceViewer from './ParagraphViewer'; +import Header from '../../Shared/Header'; +import SortableQuestion from '../Shared/SortableQuestion'; +import QuestionsList from '../Shared/QuestionsList'; +import useExamEditorStore from '@/stores/examEditor'; +import useSectionEdit from '../../Hooks/useSectionEdit'; +import validateMatchSentences from './validation'; +import setEditingAlert from '../Shared/setEditingAlert'; +import { toast } from 'react-toastify'; +import { DragEndEvent } from '@dnd-kit/core'; +import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local'; + +const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart; + + const [local, setLocal] = useState(exercise); + const [selectedParagraph, setSelectedParagraph] = useState(null); + const [showReference, setShowReference] = useState(false); + const [alerts, setAlerts] = useState([]); + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + onSave: () => { + + const isValid = validateMatchSentences(local.sentences, setAlerts); + + if (!isValid) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setLocal(exercise); + setSelectedParagraph(null); + setShowReference(false); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + } + }); + + const usedOptions = useMemo(() => { + return local.sentences.reduce((acc, sentence) => { + if (sentence.solution) { + acc.add(sentence.solution); + } + return acc; + }, new Set()); + }, [local.sentences]); + + const addHeading = () => { + setEditing(true); + const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString(); + setLocal({ + ...local, + sentences: [ + ...local.sentences, + { + id: newId, + sentence: "", + solution: "" + } + ] + }); + }; + + const updateHeading = (index: number, field: string, value: string) => { + setEditing(true); + const newSentences = [...local.sentences]; + + if (field === 'solution') { + const oldSolution = newSentences[index].solution; + if (oldSolution) { + usedOptions.delete(oldSolution); + } + } + + newSentences[index] = { ...newSentences[index], [field]: value }; + setLocal({ ...local, sentences: newSentences }); + }; + + const deleteHeading = (index: number) => { + setEditing(true); + if (local.sentences.length <= 1) { + toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`); + return; + } + + const deletedSolution = local.sentences[index].solution; + if (deletedSolution) { + usedOptions.delete(deletedSolution); + } + + const newSentences = local.sentences.filter((_, i) => i !== index); + setLocal({ ...local, sentences: newSentences }); + }; + + useEffect(() => { + validateMatchSentences(local.sentences, setAlerts); + }, [local.sentences]); + + useEffect(() => { + setEditingAlert(editing, setAlerts); + }, [editing]); + + + const handleDragEnd = (event: DragEndEvent) => { + setEditing(true); + setLocal(handleMatchSentencesReorder(event, local)); + } + + return ( +
+
+ +
+ +
+ {alerts.length > 0 && } + s.id)} + handleDragEnd={handleDragEnd} + > + {local.sentences.map((sentence, index) => ( + deleteHeading(index)} + onFocus={() => setSelectedParagraph(sentence.solution)} + > + <> + updateHeading(index, 'sentence', e.target.value)} + className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-mti-gray-dim" + placeholder={`Enter ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"} ...`} + /> +
+ +
+ +
+ ))} +
+ + +
+ + +
+ ); +}; + +export default MatchSentences; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/MatchSentences/validation.ts b/src/components/ExamEditor/Exercises/MatchSentences/validation.ts new file mode 100644 index 00000000..a96fa6f4 --- /dev/null +++ b/src/components/ExamEditor/Exercises/MatchSentences/validation.ts @@ -0,0 +1,42 @@ +import { AlertItem } from "../Shared/Alert"; + +const validateMatchSentences = ( + sentences: {id: string; sentence: string; solution: string;}[], + setAlerts: React.Dispatch> +): boolean => { + let hasErrors = false; + + const emptySentences = sentences.filter(s => !s.sentence.trim()); + if (emptySentences.length > 0) { + hasErrors = true; + setAlerts(prev => { + const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-sentence')); + return [...filteredAlerts, ...emptySentences.map(s => ({ + variant: "error" as const, + tag: `empty-sentence-${s.id}`, + description: `Heading ${s.id} is empty` + }))]; + }); + } else { + setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-sentence'))); + } + + const unmatchedSentences = sentences.filter(s => !s.solution); + if (unmatchedSentences.length > 0) { + hasErrors = true; + setAlerts(prev => { + const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence')); + return [...filteredAlerts, ...unmatchedSentences.map(s => ({ + variant: "error" as const, + tag: `unmatched-sentence-${s.id}`, + description: `Heading ${s.id} has no paragraph selected` + }))]; + }); + } else { + setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence'))); + } + + return !hasErrors; +}; + +export default validateMatchSentences; diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/UnderlineQuestion.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/UnderlineQuestion.tsx new file mode 100644 index 00000000..5ed4ce33 --- /dev/null +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/UnderlineQuestion.tsx @@ -0,0 +1,190 @@ +import { MultipleChoiceQuestion } from "@/interfaces/exam"; +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { MdEdit, MdEditOff } from "react-icons/md"; + +interface UnderlineQuestionProps { + question: MultipleChoiceQuestion; + onQuestionChange: (updatedQuestion: MultipleChoiceQuestion) => void; + onValidationChange?: (isValid: boolean) => void; +} + +interface Option { + id: string; + text?: string; + src?: string; +} + +export const UnderlineQuestion: React.FC = ({ + question, + onQuestionChange, + onValidationChange, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + + const stripUnderlineTags = (text: string = '') => text.replace(/<\/?u>/g, ''); + + const addUnderlineTags = (text: string, options: Option[]) => { + let result = text; + + // Sort options by length (longest first) to handle overlapping matches + const sortedOptions = [...options] + .filter(opt => opt.text?.trim() && opt.text.trim().length > 1) + .sort((a, b) => ((b.text?.length || 0) - (a.text?.length || 0))); + + for (const option of sortedOptions) { + if (!option.text?.trim()) continue; + + const optionText = stripUnderlineTags(option.text).trim(); + const textLower = result.toLowerCase(); + const optionLower = optionText.toLowerCase(); + + let startIndex = textLower.indexOf(optionLower); + while (startIndex !== -1) { + // Check if this portion is already underlined + const beforeTag = result.slice(Math.max(0, startIndex - 3), startIndex); + const afterTag = result.slice(startIndex + optionText.length, startIndex + optionText.length + 4); + + if (!beforeTag.includes('') && !afterTag.includes('')) { + const before = result.substring(0, startIndex); + const match = result.substring(startIndex, startIndex + optionText.length); + const after = result.substring(startIndex + optionText.length); + result = `${before}${match}${after}`; + } + + // Find next occurrence + startIndex = textLower.indexOf(optionLower, startIndex + 1); + } + } + + return result; + }; + + const validateQuestion = (q: MultipleChoiceQuestion) => { + const errors: string[] = []; + const rawPrompt = stripUnderlineTags(q.prompt).toLowerCase(); + + q.options.forEach((option) => { + if (option.text?.trim() && !rawPrompt.includes(stripUnderlineTags(option.text).trim().toLowerCase())) { + errors.push(`Option ${option.id} text not found in prompt`); + } + }); + + setValidationErrors(errors); + onValidationChange?.(errors.length === 0); + return errors.length === 0; + }; + + useEffect(() => { + validateQuestion(question); + }, [question]); + + const handlePromptChange = (value: string) => { + const newPrompt = addUnderlineTags(value, question.options); + onQuestionChange({ + ...question, + prompt: newPrompt + }); + }; + + const handleOptionChange = (optionIndex: number, value: string) => { + const updatedOptions = question.options.map((opt, idx) => + idx === optionIndex ? { ...opt, text: value } : opt + ); + + const strippedPrompt = stripUnderlineTags(question.prompt); + const newPrompt = addUnderlineTags(strippedPrompt, updatedOptions); + + onQuestionChange({ + ...question, + prompt: newPrompt, + options: updatedOptions + }); + }; + + return ( +
+
+ {isEditing ? ( + handlePromptChange(e.target.value)} + className="flex-1 p-3 border rounded-lg focus:outline-none" + placeholder="Enter text for underlining..." + /> + ) : ( +
+ )} + +
+ + {validationErrors.length > 0 && ( +
+ {validationErrors.map((error, index) => ( +
{error}
+ ))} +
+ )} + +
+ {question.options.map((option, optionIndex) => { + const isInvalidOption = option.text?.trim() && + !stripUnderlineTags(question.prompt || '').toLowerCase() + .includes(stripUnderlineTags(option.text).trim().toLowerCase()); + + return ( +
+ + handleOptionChange(optionIndex, e.target.value)} + className={clsx( + "flex-1 p-3 border rounded-lg focus:ring-2 focus:outline-none", + isInvalidOption + ? "border-red-500 focus:ring-red-500 bg-red-50" + : "focus:ring-blue-500" + )} + placeholder={`Option ${option.id}...`} + /> +
+ ); + })} +
+
+ ); +}; + +export default UnderlineQuestion; diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx new file mode 100644 index 00000000..b046dab8 --- /dev/null +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx @@ -0,0 +1,151 @@ +import Header from "@/components/ExamEditor/Shared/Header"; +import QuestionsList from "../../Shared/QuestionsList"; +import SortableQuestion from "../../Shared/SortableQuestion"; +import UnderlineQuestion from "./UnderlineQuestion"; +import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit"; +import { toast } from "react-toastify"; +import setEditingAlert from "../../Shared/setEditingAlert"; +import { LevelPart, ListeningPart, MultipleChoiceExercise, MultipleChoiceQuestion, ReadingPart } from "@/interfaces/exam"; +import useExamEditorStore from "@/stores/examEditor"; +import { useEffect, useState } from "react"; +import { MdAdd } from "react-icons/md"; +import Alert, { AlertItem } from "../../Shared/Alert"; + + +const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({ + exercise, + sectionId, +}) => { + const { currentModule, dispatch } = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + const section = state as ReadingPart | ListeningPart | LevelPart; + + const [local, setLocal] = useState(exercise); + const [alerts, setAlerts] = useState([]); + + useEffect(() => { + setLocal(exercise); + }, [exercise]); + + const updateLocal = (exercise: MultipleChoiceExercise) => { + setLocal(exercise); + setEditingAlert(true, setAlerts); + setEditing(true); + }; + + const handleQuestionChange = (questionIndex: number, updatedQuestion: MultipleChoiceQuestion) => { + const newQuestions = [...local.questions]; + newQuestions[questionIndex] = updatedQuestion; + updateLocal({ ...local, questions: newQuestions }); + }; + + const addQuestion = () => { + const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString(); + const options = Array.from({ length: 4 }, (_, i) => ({ + id: String.fromCharCode(65 + i), + text: '' + })); + + updateLocal({ + ...local, + questions: [ + ...local.questions, + { + prompt: "", + solution: "", + id: newId, + options, + variant: "text" + }, + ] + }); + }; + + const deleteQuestion = (index: number) => { + if (local.questions.length === 1) { + toast.error("There needs to be at least one question!"); + return; + } + + const newQuestions = local.questions.filter((_, i) => i !== index); + updateLocal({ ...local, questions: newQuestions }); + }; + + const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + setAlerts([]); + const newSection = { + ...section, + exercises: section.exercises.map((ex) => + ex.id === local.id ? local : ex + ) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setLocal(exercise); + setEditing(false); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + }); + + return ( +
+
+ {alerts.length > 0 && } + +
+ q.id)} + handleDragEnd={()=> {}} + > + {local.questions.map((question, questionIndex) => ( + deleteQuestion(questionIndex)} + > + + handleQuestionChange(questionIndex, updatedQuestion) + } + /> + + ))} + + + +
+
+ ); +}; + +export default UnderlineMultipleChoice; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx new file mode 100644 index 00000000..b3686cfa --- /dev/null +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { + MdAdd, + MdEdit, + MdEditOff, +} from 'react-icons/md'; +import { ReadingPart, MultipleChoiceExercise, MultipleChoiceQuestion, LevelPart, ListeningPart } from '@/interfaces/exam'; +import clsx from 'clsx'; +import useExamEditorStore from '@/stores/examEditor'; +import { toast } from 'react-toastify'; +import { DragEndEvent } from '@dnd-kit/core'; +import useSectionEdit from '@/components/ExamEditor/Hooks/useSectionEdit'; +import Header from '@/components/ExamEditor/Shared/Header'; +import Alert, { AlertItem } from '../../Shared/Alert'; +import QuestionsList from '../../Shared/QuestionsList'; +import SortableQuestion from '../../Shared/SortableQuestion'; +import setEditingAlert from '../../Shared/setEditingAlert'; +import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local'; + +interface MultipleChoiceProps { + exercise: MultipleChoiceExercise; + sectionId: number; + optionsQuantity: number; +} + +const validateMultipleChoiceQuestions = ( + questions: MultipleChoiceQuestion[], + optionsQuantity: number, + setAlerts: React.Dispatch> +) => { + const validationAlerts: AlertItem[] = []; + + questions.forEach((question, index) => { + if (!question.prompt.trim()) { + validationAlerts.push({ + variant: 'error', + tag: `missing-prompt-${index}`, + description: `Question ${index + 1} is missing a prompt` + }); + } + if (!question.solution) { + validationAlerts.push({ + variant: 'error', + tag: `missing-solution-${index}`, + description: `Question ${index + 1} is missing a solution` + }); + } + if (question.options.length !== optionsQuantity) { + validationAlerts.push({ + variant: 'error', + tag: `invalid-options-${index}`, + description: `Question ${index + 1} must have exactly ${optionsQuantity} options` + }); + } + question.options.forEach((option, optionIndex) => { + if (option.text && option.text.trim() === "") { + validationAlerts.push({ + variant: 'error', + tag: `empty-option-${index}-${optionIndex}`, + description: `Question ${index + 1} has an empty option` + }); + } + }); + }); + + setAlerts(prev => { + const editingAlert = prev.find(alert => alert.tag === 'editing'); + return [...validationAlerts, ...(editingAlert ? [editingAlert] : [])]; + }); + + return validationAlerts.length === 0; +}; + +const MultipleChoice: React.FC = ({ exercise, sectionId, optionsQuantity }) => { + const { currentModule, dispatch} = useExamEditorStore(); + const { state } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const section = state as ReadingPart | ListeningPart| LevelPart; + + const [local, setLocal] = useState(exercise); + const [editingPrompt, setEditingPrompt] = useState(false); + const [alerts, setAlerts] = useState([]); + + const updateLocal = (exercise: MultipleChoiceExercise) => { + setLocal(exercise); + setEditingAlert(true, setAlerts); + setEditing(true); + }; + + const updateQuestion = (index: number, field: string, value: string) => { + const newQuestions = [...local.questions]; + newQuestions[index] = { ...newQuestions[index], [field]: value }; + updateLocal({ ...local, questions: newQuestions }); + }; + + const updateOption = (questionIndex: number, optionIndex: number, value: string) => { + const newQuestions = [...local.questions]; + const newOptions = [...newQuestions[questionIndex].options]; + newOptions[optionIndex] = { ...newOptions[optionIndex], text: value }; + newQuestions[questionIndex] = { ...newQuestions[questionIndex], options: newOptions }; + updateLocal({ ...local, questions: newQuestions }); + }; + + const addQuestion = () => { + const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString(); + const options = Array.from({ length: optionsQuantity }, (_, i) => ({ + id: String.fromCharCode(65 + i), + text: '' + })); + + updateLocal({ + ...local, + questions: [ + ...local.questions, + { + prompt: "", + solution: "", + id: newId, + options, + variant: "text" + }, + ] + }); + }; + + const deleteQuestion = (index: number) => { + if (local.questions.length === 1) { + toast.error("There needs to be at least one question!"); + return; + } + + const newQuestions = local.questions.filter((_, i) => i !== index); + const minId = Math.min(...newQuestions.map(q => parseInt(q.id))); + + const updatedQuestions = newQuestions.map((question, i) => ({ + ...question, + id: String(minId + i) + })); + + updateLocal({ ...local, questions: updatedQuestions }); + }; + + const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + const isValid = validateMultipleChoiceQuestions( + local.questions, + optionsQuantity, + setAlerts + ); + + if (!isValid) { + toast.error("Please fix the errors before saving!"); + return; + } + + setEditing(false); + setAlerts([]); + const newSection = { + ...section, + exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + onDiscard: () => { + setLocal(exercise); + }, + onMode: () => { + const newSection = { + ...section, + exercises: section.exercises.filter((ex) => ex.id !== local.id) + }; + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "REORDER_EXERCISES" }); + }, + }); + + useEffect(() => { + validateMultipleChoiceQuestions(local.questions, optionsQuantity, setAlerts); + }, [local.questions, optionsQuantity]); + + const handleDragEnd = (event: DragEndEvent) => { + setEditingAlert(true, setAlerts); + setEditing(true); + setLocal(handleMultipleChoiceReorder(event, local)); + }; + + return ( +
+
+ {alerts.length > 0 && } + + +
+ {editingPrompt ? ( +