Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
301
src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx
Normal file
301
src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx
Normal file
@@ -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<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 [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 (
|
||||
<div className="space-y-4">
|
||||
<BlanksEditor
|
||||
alerts={alerts}
|
||||
editing={editing}
|
||||
state={blanksState}
|
||||
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)}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
onDelete={modeHandle}
|
||||
setEditing={setEditing}
|
||||
>
|
||||
<>
|
||||
{!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}
|
||||
isUsed={isWordUsed(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;
|
||||
Reference in New Issue
Block a user