353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import { Difficulty, FillBlanksExercise, ReadingPart } from "@/interfaces/exam";
|
|
import { useCallback, 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 "../BlanksReducer";
|
|
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 PromptEdit from "../../Shared/PromptEdit";
|
|
import { uuidv4 } from "@firebase/util";
|
|
|
|
interface Word {
|
|
letter: string;
|
|
word: string;
|
|
}
|
|
|
|
const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
|
const { currentModule, dispatch } = useExamEditorStore();
|
|
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
|
const { state } = useExamEditorStore(
|
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
|
);
|
|
|
|
const section = state as ReadingPart;
|
|
|
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
|
|
|
const [local, setLocal] = useState(exercise);
|
|
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
|
const [answers, setAnswers] = useState<Map<string, string>>(
|
|
new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))
|
|
);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [newWord, setNewWord] = useState('');
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
const updateLocal = (exercise: FillBlanksExercise) => {
|
|
setLocal(exercise);
|
|
setEditingAlert(true, setAlerts);
|
|
setEditing(true);
|
|
};
|
|
|
|
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
|
text: exercise.text || "",
|
|
blanks: [],
|
|
selectedBlankId: null,
|
|
draggedItemId: null,
|
|
textMode: false,
|
|
setEditing,
|
|
});
|
|
|
|
const { handleSave, handleDiscard, handleDelete, handlePractice } = 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]) => ({
|
|
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
|
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, module: currentModule } });
|
|
},
|
|
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 });
|
|
|
|
},
|
|
onDelete: () => {
|
|
const newSection = {
|
|
...section,
|
|
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
|
};
|
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
|
},
|
|
onPractice: () => {
|
|
const updatedExercise = {
|
|
...local,
|
|
isPractice: !local.isPractice,
|
|
};
|
|
const newState = { ...section };
|
|
newState.exercises = newState.exercises.map((ex) =>
|
|
ex.id === exercise.id ? updatedExercise : ex
|
|
);
|
|
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
|
}
|
|
});
|
|
|
|
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]) => ({
|
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
|
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]) => ({
|
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
|
id,
|
|
solution
|
|
}))
|
|
};
|
|
});
|
|
};
|
|
|
|
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]) => ({
|
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
|
id,
|
|
solution
|
|
}))
|
|
};
|
|
});
|
|
};
|
|
|
|
const handleBlankRemove = (blankId: number) => {
|
|
if (!editing) setEditing(true);
|
|
|
|
const newAnswers = new Map(answers);
|
|
newAnswers.delete(blankId.toString());
|
|
setAnswers(newAnswers);
|
|
|
|
setLocal(prev => ({
|
|
...prev,
|
|
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
|
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
|
id,
|
|
solution
|
|
}))
|
|
}));
|
|
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
|
|
};
|
|
|
|
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])
|
|
|
|
const saveDifficulty = useCallback((diff: Difficulty) => {
|
|
if (!difficulty.includes(diff)) {
|
|
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
|
}
|
|
const updatedExercise = { ...exercise, difficulty: diff };
|
|
const newState = { ...section };
|
|
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
|
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<BlanksEditor
|
|
alerts={alerts}
|
|
editing={editing}
|
|
state={blanksState}
|
|
difficulty={exercise.difficulty}
|
|
saveDifficulty={saveDifficulty}
|
|
blanksDispatcher={blanksDispatcher}
|
|
description="Place blanks and assign words from the word bank"
|
|
initialText={local.text}
|
|
module={currentModule}
|
|
showBlankBank={true}
|
|
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
|
onBlankRemove={handleBlankRemove}
|
|
onSave={handleSave}
|
|
onDiscard={handleDiscard}
|
|
onDelete={handleDelete}
|
|
setEditing={setEditing}
|
|
onPractice={handlePractice}
|
|
isEvaluationEnabled={!local.isPractice}
|
|
prompt={local.prompt}
|
|
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
|
|
>
|
|
<>
|
|
{!blanksState.textMode && <Card className="p-4">
|
|
<CardContent>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<div className="text-lg font-semibold">Word Bank</div>
|
|
<button
|
|
onClick={() => setIsEditMode(!isEditMode)}
|
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
{isEditMode ?
|
|
<MdEditOff size={20} className="text-gray-500" /> :
|
|
<MdEdit size={20} className="text-gray-500" />
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
|
{(local.words as Word[]).map((wordItem, index) => (
|
|
<FillBlanksWord
|
|
key={wordItem.letter}
|
|
letter={wordItem.letter}
|
|
word={wordItem.word}
|
|
isSelected={answers.get(selectedBlankId || '') === wordItem.word}
|
|
onClick={() => handleWordSelect(wordItem.word)}
|
|
onRemove={isEditMode ? () => handleRemoveWord(index) : undefined}
|
|
onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined}
|
|
isEditMode={isEditMode}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{isEditMode && (
|
|
<div className="flex flex-row mt-8">
|
|
<input
|
|
type="text"
|
|
value={newWord}
|
|
onChange={(e) => 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=""
|
|
/>
|
|
<button
|
|
onClick={handleAddWord}
|
|
disabled={!isEditMode || newWord === ""}
|
|
className="px-4 bg-blue-500 text-white rounded-r-md border border-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<FaPlus className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
}
|
|
</>
|
|
</BlanksEditor>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FillBlanksLetters; |