Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
129
src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx
Normal file
129
src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx
Normal file
@@ -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<BlankProps> = ({
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={clsx(
|
||||
"group relative inline-flex items-center gap-2 px-2 py-1.5 rounded-lg select-none",
|
||||
"transform-gpu transition-colors duration-150",
|
||||
"hover:ring-2 hover:ring-offset-1 shadow-sm",
|
||||
(
|
||||
isSelected ? (
|
||||
isDragging ?
|
||||
`bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50` :
|
||||
`bg-ielts-${module}/20 bg-ielts-${module}/80 hover:ring-ielts-${module}/40`
|
||||
)
|
||||
: `bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50`
|
||||
),
|
||||
!disabled && (variant === "text" ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"),
|
||||
disabled && "cursor-default",
|
||||
variant === "bank" && "w-12"
|
||||
)}
|
||||
onClick={variant === "bank" ? handleClick : undefined}
|
||||
{...dragProps}
|
||||
role="button"
|
||||
>
|
||||
{variant === "text" && (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xl p-1.5 -ml-1 rounded-md",
|
||||
"transition-colors duration-150"
|
||||
)}
|
||||
title="Drag to reorder"
|
||||
>
|
||||
{isSelected ?
|
||||
<MdDragIndicator className="transform scale-125" color="white" /> :
|
||||
<MdDragIndicator className="transform scale-125" color="#898492" />
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(
|
||||
"font-semibold px-1 text-mti-gray-taupe",
|
||||
isSelected && !isDragging && "text-white"
|
||||
)}>
|
||||
{id}
|
||||
</span>
|
||||
|
||||
{onRemove && !isDragging && (
|
||||
<ConfirmDeleteBtn
|
||||
onDelete={() => onRemove(id)}
|
||||
size="md"
|
||||
position="top-right"
|
||||
className="-translate-y-2 translate-x-1.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropZone: React.FC<{ index: number, module: string; }> = ({ index, module }) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `drop-${index}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={setNodeRef}
|
||||
className={clsx(
|
||||
"inline-block h-6 w-4 mx-px transition-all duration-200 select-none",
|
||||
isOver ? `bg-ielts-${module}/20 w-4.5` : `bg-transparent hover:bg-ielts-${module}/20`
|
||||
)}
|
||||
role="presentation"
|
||||
/>
|
||||
);
|
||||
};
|
||||
247
src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx
Normal file
247
src/components/ExamEditor/Exercises/Blanks/FillBlanksReducer.tsx
Normal file
@@ -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: '<br>',
|
||||
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<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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<Props> = ({
|
||||
letter,
|
||||
word,
|
||||
isSelected,
|
||||
isUsed,
|
||||
onClick,
|
||||
onRemove,
|
||||
onEdit,
|
||||
isEditMode
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full flex items-center gap-2">
|
||||
{isEditMode ? (
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
|
||||
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={word}
|
||||
onChange={(e) => onEdit?.(e.target.value)}
|
||||
className="w-full min-w-0 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isUsed}
|
||||
className={`
|
||||
min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border text-left transition-colors
|
||||
${isUsed ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-blue-50'}
|
||||
${isSelected ? 'border-blue-500 bg-blue-100' : 'border-gray-200'}
|
||||
`}
|
||||
>
|
||||
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
|
||||
<span className="truncate">{word}</span>
|
||||
</button>
|
||||
)}
|
||||
{isEditMode && onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1 rounded text-red-500 hover:bg-gray-100 shrink-0"
|
||||
aria-label="Remove word"
|
||||
>
|
||||
<MdDelete className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default FillBlanksWord;
|
||||
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;
|
||||
@@ -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<MCOptionProps> = ({
|
||||
id,
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
isEditMode,
|
||||
onEdit,
|
||||
onRemove
|
||||
}) => {
|
||||
const optionKeys = ['A', 'B', 'C', 'D'] as const;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Question {id}</span>
|
||||
{isEditMode && onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1 rounded text-red-500 hover:bg-gray-100"
|
||||
aria-label="Remove question"
|
||||
>
|
||||
<MdDelete className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{optionKeys.map((key) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
{isEditMode ? (
|
||||
<div className="flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
|
||||
<span className="font-medium min-w-[24px] text-center">{key}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={options[key]}
|
||||
onChange={(e) => onEdit?.(key, e.target.value)}
|
||||
className="w-full focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onSelect(key)}
|
||||
className={clsx(
|
||||
"flex-1 flex items-center gap-2 p-2 rounded-md border transition-colors text-left",
|
||||
selectedOption === key
|
||||
? "border-blue-500 bg-blue-100"
|
||||
: "border-gray-200 hover:bg-blue-50"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium min-w-[24px] text-center">{key}</span>
|
||||
<span>{options[key]}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCOption;
|
||||
@@ -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<AlertItem[]>([]);
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||
|
||||
const [answers, setAnswers] = useState<Map<string, string>>(() => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<BlanksEditor
|
||||
alerts={alerts}
|
||||
editing={editing}
|
||||
state={blanksState}
|
||||
blanksDispatcher={blanksDispatcher}
|
||||
description="Place blanks and select the correct answer from multiple choice options"
|
||||
initialText={local.text}
|
||||
module={currentModule}
|
||||
showBlankBank={true}
|
||||
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
onDelete={modeHandle}
|
||||
setEditing={setEditing}
|
||||
>
|
||||
{!blanksState.textMode && selectedBlankId && (
|
||||
<Card className="p-4">
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-lg font-semibold">Multiple Choice Options</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>
|
||||
|
||||
{(local.words as FillBlanksMCOption[]).map((mcOption) => {
|
||||
if (mcOption.id.toString() !== selectedBlankId) return null;
|
||||
|
||||
return (
|
||||
<MCOption
|
||||
key={mcOption.id}
|
||||
id={mcOption.id}
|
||||
options={mcOption.options}
|
||||
selectedOption={answers.get(selectedBlankId)}
|
||||
onSelect={(option) => 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)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</BlanksEditor>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FillBlanksMC;
|
||||
@@ -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<AlternativeSolutionProps> = ({
|
||||
solutions,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onEdit,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-2 mt-4">
|
||||
{solutions.map((solution, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={solution}
|
||||
onChange={(e) => 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}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete solution"
|
||||
>
|
||||
<MdDelete size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="w-full mt-2 p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add Alternative Solution
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlternativeSolutions;
|
||||
@@ -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<AlertItem[]>([]);
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<BlanksEditor
|
||||
title="Write Blanks: Fill"
|
||||
alerts={alerts}
|
||||
editing={editing}
|
||||
state={blanksState}
|
||||
blanksDispatcher={blanksDispatcher}
|
||||
description={local.prompt}
|
||||
initialText={local.text}
|
||||
module={currentModule}
|
||||
showBlankBank={true}
|
||||
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
onDelete={modeHandle}
|
||||
setEditing={setEditing}
|
||||
>
|
||||
{!blanksState.textMode && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-lg font-semibold">
|
||||
{selectedBlankId
|
||||
? `Solutions for Blank ${selectedBlankId}`
|
||||
: "Click a blank to edit its solutions"}
|
||||
</span>
|
||||
{selectedBlankId && (
|
||||
<span className="text-sm text-gray-500">
|
||||
Max words per solution: {local.maxWords}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{selectedBlankId && (
|
||||
<AlternativeSolutions
|
||||
solutions={local.solutions.find(s => s.id === selectedBlankId)?.solution || []}
|
||||
onAdd={() => handleAddSolution(selectedBlankId)}
|
||||
onRemove={(index: number) => handleRemoveSolution(selectedBlankId, index)}
|
||||
onEdit={(index: number, value: string) => handleEditSolution(selectedBlankId, index, value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</BlanksEditor>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriteBlanksFill;
|
||||
@@ -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<React.SetStateAction<AlertItem[]>>
|
||||
): 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;
|
||||
};
|
||||
246
src/components/ExamEditor/Exercises/Blanks/index.tsx
Normal file
246
src/components/ExamEditor/Exercises/Blanks/index.tsx
Normal file
@@ -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<React.SetStateAction<boolean>>;
|
||||
blanksDispatcher: React.Dispatch<BlanksAction>
|
||||
onBlankSelect?: (blankId: number | null) => void;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onDelete: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BlanksEditor: React.FC<Props> = ({
|
||||
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 (
|
||||
<div className="space-y-4 p-4">
|
||||
<Header
|
||||
title={title}
|
||||
description={description}
|
||||
editing={editing}
|
||||
handleSave={onSave}
|
||||
modeHandle={onDelete}
|
||||
handleDiscard={onDiscard}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<Card>
|
||||
<CardContent className="p-4 text-white font-semibold flex gap-2">
|
||||
<button
|
||||
onClick={() => blanksDispatcher({ type: "ADD_BLANK" }) }
|
||||
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
|
||||
>
|
||||
Add Blank
|
||||
</button>
|
||||
<button
|
||||
onClick={() => blanksDispatcher({ type: "TOGGLE_EDIT_MODE" })}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-md transition-colors",
|
||||
`bg-ielts-${module} text-white hover:bg-ielts-${module}/50`
|
||||
)}
|
||||
>
|
||||
{state.textMode ? "Drag Mode" : "Text Mode"}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={modifiers}
|
||||
measuring={measuring}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
{state.textMode ? (
|
||||
<AutoExpandingTextArea
|
||||
value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
|
||||
onChange={(text) => { 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..."
|
||||
/>
|
||||
) : (
|
||||
<div className="leading-relaxed p-4">
|
||||
{tokens.map((token, index) => {
|
||||
const isWordToken = token.type === 'text' && !token.isWhitespace;
|
||||
const showDropZone = isWordToken || token.type === 'blank';
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{showDropZone && <DropZone index={index} module={module} />}
|
||||
{token.type === 'blank' ? (
|
||||
<Blank
|
||||
id={token.id}
|
||||
module={module}
|
||||
variant="text"
|
||||
isSelected={token.id === state.selectedBlankId}
|
||||
isDragging={state.draggedItemId === `text-blank-${token.id}`}
|
||||
/>
|
||||
) : token.isLineBreak ? (
|
||||
<br />
|
||||
) : (
|
||||
<span className="select-none">{token.content}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{tokens.length > 0 &&
|
||||
tokens[tokens.length - 1].type === 'text' && (
|
||||
<DropZone index={tokens.length} module={module} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
{(!state.textMode && showBlankBank) && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap gap-2 p-4">
|
||||
{state.blanks.map(blank => (
|
||||
<Blank
|
||||
key={blank.id}
|
||||
id={blank.id}
|
||||
module={module}
|
||||
variant="bank"
|
||||
isSelected={blank.id === state.selectedBlankId}
|
||||
isDragging={state.draggedItemId === `bank-blank-${blank.id}`}
|
||||
onSelect={handleBlankSelect}
|
||||
onRemove={handleBlankRemove}
|
||||
disabled={state.textMode}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{children}
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlanksEditor;
|
||||
38
src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts
Normal file
38
src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AlertItem } from "../Shared/Alert";
|
||||
import { BlankState } from "./FillBlanksReducer";
|
||||
|
||||
|
||||
const validateBlanks = (
|
||||
blanks: BlankState[],
|
||||
answers: Map<string, string>,
|
||||
alerts: AlertItem[],
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>,
|
||||
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;
|
||||
@@ -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<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const ReferenceViewer: React.FC<Props> = ({ showReference, selectedReference, options, setShowReference, headings = true}) => (
|
||||
<div
|
||||
className={`fixed inset-y-0 right-0 w-96 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${showReference ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 border-b bg-gray-50 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-gray-800">{headings ? "Reference Paragraphs" : "Authors"}</h3>
|
||||
<button
|
||||
onClick={() => setShowReference(false)}
|
||||
className="p-2 hover:bg-gray-200 rounded-full"
|
||||
>
|
||||
<MdVisibilityOff size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{options.map((option) => (
|
||||
<Card key={option.id} className={`bg-gray-50 transition-all duration-200 ${selectedReference === option.id ? 'ring-2 ring-blue-500' : ''}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-md text-black">{headings ? "Paragraph" : "Author" } {option.id}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">{option.sentence}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ReferenceViewer;
|
||||
230
src/components/ExamEditor/Exercises/MatchSentences/index.tsx
Normal file
230
src/components/ExamEditor/Exercises/MatchSentences/index.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [showReference, setShowReference] = useState(false);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
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<string>());
|
||||
}, [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 (
|
||||
<div className="flex flex-col mx-auto p-2">
|
||||
<Header
|
||||
title={exercise.variant && exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
|
||||
description={`Edit ${exercise.variant && exercise.variant == "ideaMatch" ? "ideas/opinions" : "headings"} and their matches`}
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowReference(!showReference)}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{showReference ? <MdVisibilityOff size={18} /> : <MdVisibility size={18} />}
|
||||
{showReference ? 'Hide Reference' : 'Show Reference'}
|
||||
</button>
|
||||
</Header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<QuestionsList
|
||||
ids={local.sentences.map(s => s.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
>
|
||||
{local.sentences.map((sentence, index) => (
|
||||
<SortableQuestion
|
||||
key={sentence.id}
|
||||
id={sentence.id}
|
||||
index={index}
|
||||
deleteQuestion={() => deleteHeading(index)}
|
||||
onFocus={() => setSelectedParagraph(sentence.solution)}
|
||||
>
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={sentence.sentence}
|
||||
onChange={(e) => 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"} ...`}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={sentence.solution}
|
||||
onChange={(e) => {
|
||||
updateHeading(index, 'solution', e.target.value);
|
||||
setSelectedParagraph(e.target.value);
|
||||
}}
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white text-mti-gray-dim"
|
||||
>
|
||||
<option value="">Select matching {exercise.variant == "ideaMatch" ? "author" : "paragraph"}...</option>
|
||||
{local.options.map((option) => {
|
||||
const isUsed = usedOptions.has(option.id);
|
||||
const isCurrentSelection = sentence.solution === option.id;
|
||||
|
||||
return (
|
||||
<option
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={isUsed && !isCurrentSelection}
|
||||
>
|
||||
{exercise.variant == "ideaMatch" ? "Author" : "Paragraph"} {option.id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
|
||||
<button
|
||||
onClick={addHeading}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Match
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ReferenceViewer
|
||||
headings={exercise.variant !== "ideaMatch"}
|
||||
showReference={showReference}
|
||||
selectedReference={selectedParagraph}
|
||||
options={local.options}
|
||||
setShowReference={setShowReference}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchSentences;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AlertItem } from "../Shared/Alert";
|
||||
|
||||
const validateMatchSentences = (
|
||||
sentences: {id: string; sentence: string; solution: string;}[],
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): 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;
|
||||
@@ -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<UnderlineQuestionProps> = ({
|
||||
question,
|
||||
onQuestionChange,
|
||||
onValidationChange,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
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('<u>') && !afterTag.includes('</u>')) {
|
||||
const before = result.substring(0, startIndex);
|
||||
const match = result.substring(startIndex, startIndex + optionText.length);
|
||||
const after = result.substring(startIndex + optionText.length);
|
||||
result = `${before}<u>${match}</u>${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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
value={stripUnderlineTags(question.prompt)}
|
||||
onChange={(e) => handlePromptChange(e.target.value)}
|
||||
className="flex-1 p-3 border rounded-lg focus:outline-none"
|
||||
placeholder="Enter text for underlining..."
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 p-3 border rounded-lg min-h-[50px]"
|
||||
dangerouslySetInnerHTML={{ __html: question.prompt || '' }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isEditing ?
|
||||
<MdEditOff size={24} className="text-gray-500" /> :
|
||||
<MdEdit size={24} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="text-red-500 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option, optionIndex) => {
|
||||
const isInvalidOption = option.text?.trim() &&
|
||||
!stripUnderlineTags(question.prompt || '').toLowerCase()
|
||||
.includes(stripUnderlineTags(option.text).trim().toLowerCase());
|
||||
|
||||
return (
|
||||
<div key={option.id} className="flex gap-2">
|
||||
<label
|
||||
className={clsx(
|
||||
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
|
||||
question.solution === option.id
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${question.id}`}
|
||||
value={option.id}
|
||||
checked={question.solution === option.id}
|
||||
onChange={(e) => onQuestionChange({
|
||||
...question,
|
||||
solution: e.target.value
|
||||
})}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option.id}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stripUnderlineTags(option.text || '')}
|
||||
onChange={(e) => 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}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnderlineQuestion;
|
||||
@@ -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<AlertItem[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title='Underline Multiple Choice Exercise'
|
||||
description="Edit questions with 4 underline options each"
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
handleDragEnd={()=> {}}
|
||||
>
|
||||
{local.questions.map((question, questionIndex) => (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={questionIndex}
|
||||
deleteQuestion={() => deleteQuestion(questionIndex)}
|
||||
>
|
||||
<UnderlineQuestion
|
||||
question={question}
|
||||
onQuestionChange={(updatedQuestion) =>
|
||||
handleQuestionChange(questionIndex, updatedQuestion)
|
||||
}
|
||||
/>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnderlineMultipleChoice;
|
||||
@@ -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<React.SetStateAction<AlertItem[]>>
|
||||
) => {
|
||||
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<MultipleChoiceProps> = ({ 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<AlertItem[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title='Multiple Choice Exercise'
|
||||
description={`Edit questions with ${optionsQuantity} options each`}
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{editingPrompt ? (
|
||||
<textarea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={local.prompt}
|
||||
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
|
||||
onBlur={() => setEditingPrompt(false)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||
<p className="text-gray-600">{local.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editingPrompt ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
>
|
||||
{local.questions.map((question, questionIndex) => (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={questionIndex}
|
||||
deleteQuestion={deleteQuestion}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={question.prompt}
|
||||
onChange={(e) => updateQuestion(questionIndex, 'prompt', e.target.value)}
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Enter question..."
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option, optionIndex) => (
|
||||
<div key={option.id} className="flex gap-2">
|
||||
<label
|
||||
className={clsx(
|
||||
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
|
||||
question.solution === option.id
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${question.id}`}
|
||||
value={option.id}
|
||||
checked={question.solution === option.id}
|
||||
onChange={(e) => updateQuestion(questionIndex, 'solution', e.target.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option.id}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={option.text}
|
||||
onChange={(e) => updateOption(questionIndex, optionIndex, e.target.value)}
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder={`Option ${option.id}...`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipleChoice;
|
||||
17
src/components/ExamEditor/Exercises/MultipleChoice/index.tsx
Normal file
17
src/components/ExamEditor/Exercises/MultipleChoice/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MultipleChoiceExercise } from "@/interfaces/exam";
|
||||
import Vanilla from "./Vanilla";
|
||||
import MultipleChoiceUnderline from "./Underline";
|
||||
|
||||
const MultipleChoice: React.FC<{sectionId: number; exercise: MultipleChoiceExercise}> = (props) => {
|
||||
const {exercise} = props;
|
||||
|
||||
const length = exercise.questions[0].options.length;
|
||||
|
||||
if (exercise.questions[0].prompt.includes('<u>')) {
|
||||
return <MultipleChoiceUnderline {...props} />
|
||||
}
|
||||
|
||||
return (<Vanilla {...props} optionsQuantity={length}/>);
|
||||
}
|
||||
|
||||
export default MultipleChoice;
|
||||
61
src/components/ExamEditor/Exercises/Shared/Alert.tsx
Normal file
61
src/components/ExamEditor/Exercises/Shared/Alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import clsx from "clsx";
|
||||
import { BiErrorCircle } from "react-icons/bi";
|
||||
import { IoInformationCircle } from "react-icons/io5";
|
||||
|
||||
export interface AlertItem {
|
||||
variant: "info" | "error";
|
||||
description: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
alerts: AlertItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Alert: React.FC<Props> = ({ alerts, className }) => {
|
||||
const hasError = alerts.some(alert => alert.variant === "error");
|
||||
const alertsToShow = hasError ? alerts.filter(alert => alert.variant === "error") : alerts;
|
||||
|
||||
if (alertsToShow.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-2", className)}>
|
||||
{alertsToShow.map((alert, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
"border rounded-xl flex items-center gap-2 py-2 px-4",
|
||||
{
|
||||
'bg-amber-50': alert.variant === 'info',
|
||||
'bg-red-50': alert.variant === 'error'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{alert.variant === 'info' ? (
|
||||
<IoInformationCircle
|
||||
className="h-5 w-5 text-amber-700"
|
||||
/>
|
||||
) : (
|
||||
<BiErrorCircle
|
||||
className="h-5 w-5 text-red-700"
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className={clsx(
|
||||
"font-medium py-0.5",
|
||||
{
|
||||
'text-amber-700': alert.variant === 'info',
|
||||
'text-red-700': alert.variant === 'error'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{alert.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
14
src/components/ExamEditor/Exercises/Shared/GenLoader.tsx
Normal file
14
src/components/ExamEditor/Exercises/Shared/GenLoader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
const GenLoader: React.FC<{module: string, custom?: string, className?: string}> = ({module, custom, className}) => {
|
||||
return (
|
||||
<div className={clsx("w-full cursor-text px-7 py-8 border-2 border-mti-gray-platinum rounded-3xl", className)}>
|
||||
<div className="flex flex-col items-center justify-center animate-pulse">
|
||||
<span className={`loading loading-infinity w-32 bg-ielts-${module}`} />
|
||||
<span className={`font-bold text-2xl text-ielts-${module}`}>{`${custom ? custom : "Generating..."}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GenLoader;
|
||||
34
src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx
Normal file
34
src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
ids: string[];
|
||||
handleDragEnd: (event: any) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const QuestionsList: React.FC<Props> = ({ ids, handleDragEnd, children }) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={ids}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionsList;
|
||||
134
src/components/ExamEditor/Exercises/Shared/Script.tsx
Normal file
134
src/components/ExamEditor/Exercises/Shared/Script.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Script } from '@/interfaces/exam';
|
||||
import { FaFemale, FaMale } from "react-icons/fa";
|
||||
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const colorOptions = [
|
||||
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange',
|
||||
'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
|
||||
];
|
||||
|
||||
interface Speaker {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: 'male' | 'female';
|
||||
color: string;
|
||||
position: 'left' | 'right';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
script?: Script;
|
||||
setScript: React.Dispatch<React.SetStateAction<Script | undefined>>;
|
||||
editing?: boolean;
|
||||
}
|
||||
|
||||
const ScriptRender: React.FC<Props> = ({ script, setScript, editing = false }) => {
|
||||
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
|
||||
if (!script || typeof script === 'string') return [];
|
||||
|
||||
const uniqueSpeakers = new Map();
|
||||
const usedColors = new Set();
|
||||
let isLeft = true;
|
||||
|
||||
script.forEach((line, index) => {
|
||||
if (!uniqueSpeakers.has(line.name)) {
|
||||
const availableColors = colorOptions.filter(color => !usedColors.has(color));
|
||||
if (availableColors.length === 0) {
|
||||
usedColors.clear();
|
||||
}
|
||||
const randomColor = availableColors[Math.floor(Math.random() * availableColors.length)];
|
||||
usedColors.add(randomColor);
|
||||
|
||||
uniqueSpeakers.set(line.name, {
|
||||
id: index,
|
||||
name: line.name,
|
||||
gender: line.gender,
|
||||
color: randomColor,
|
||||
position: isLeft ? 'left' : 'right'
|
||||
});
|
||||
isLeft = !isLeft;
|
||||
}
|
||||
});
|
||||
return Array.from(uniqueSpeakers.values());
|
||||
});
|
||||
|
||||
const speakerProperties = useMemo(() => {
|
||||
return speakers.reduce((acc, speaker) => {
|
||||
acc[speaker.name] = {
|
||||
color: speaker.color,
|
||||
position: speaker.position
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, { color: string; position: 'left' | 'right' }>);
|
||||
}, [speakers]);
|
||||
|
||||
if (script === undefined) return null;
|
||||
|
||||
if (typeof script === 'string') {
|
||||
return (
|
||||
<div className="w-full px-4">
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
className="w-full p-3 border rounded bg-white"
|
||||
value={script}
|
||||
onChange={(text) => setScript(text)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-700 p-3 bg-gray-100 rounded-lg" dangerouslySetInnerHTML={{ __html: script.split("\n").join("<br>") }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const updateMessage = (index: number, newText: string) => {
|
||||
setScript([
|
||||
...script.slice(0, index),
|
||||
{ ...script[index], text: newText },
|
||||
...script.slice(index + 1)
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full px-4">
|
||||
<div className="space-y-2">
|
||||
{script.map((line, index) => {
|
||||
const { color, position } = speakerProperties[line.name];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-start gap-2 ${position === 'left' ? 'justify-start' : 'justify-end'}`}
|
||||
>
|
||||
<div className="flex flex-col w-[50%]">
|
||||
<div className={clsx('flex', position !== 'left' && 'self-end')}>
|
||||
{line.gender === 'male' ? (
|
||||
<FaMale className="w-5 h-5 text-blue-500 mb-1" />
|
||||
) : (
|
||||
<FaFemale className="w-5 h-5 text-pink-500 mb-1" />
|
||||
)}
|
||||
<span className="text-sm mb-1">
|
||||
{line.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`rounded-lg p-3 bg-${color}-100`}>
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
className="w-full p-2 border rounded bg-white"
|
||||
value={line.text}
|
||||
onChange={(text) => updateMessage(index, text)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-700">{line.text}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptRender;
|
||||
155
src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx
Normal file
155
src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { MdDragIndicator, MdDelete, MdEdit, MdEditOff } from 'react-icons/md';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
index: number;
|
||||
deleteQuestion: (index: any) => void;
|
||||
onFocus?: () => void;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'writeBlanks' | 'del-up';
|
||||
title?: string;
|
||||
onQuestionChange?: (value: string) => void;
|
||||
questionText?: string;
|
||||
}
|
||||
|
||||
const SortableQuestion: React.FC<Props> = ({
|
||||
id,
|
||||
index,
|
||||
deleteQuestion,
|
||||
children,
|
||||
extra,
|
||||
onFocus,
|
||||
variant = 'default',
|
||||
questionText = "",
|
||||
onQuestionChange
|
||||
}) => {
|
||||
const [isEditingQuestion, setIsEditingQuestion] = useState(false);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
if (variant === 'writeBlanks') {
|
||||
return (
|
||||
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-stretch gap-4">
|
||||
<div className='flex flex-col flex-none w-12'>
|
||||
<div className="flex-none">
|
||||
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
|
||||
</div>
|
||||
<div
|
||||
className='flex-1 flex items-center justify-center group'
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors">
|
||||
<MdDragIndicator size={24} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{isEditingQuestion ? (
|
||||
<input
|
||||
type="text"
|
||||
value={questionText}
|
||||
onChange={(e) => onQuestionChange?.(e.target.value)}
|
||||
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
autoFocus
|
||||
onBlur={() => setIsEditingQuestion(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsEditingQuestion(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 font-bold text-gray-800">{questionText}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<button
|
||||
onClick={() => setIsEditingQuestion(!isEditingQuestion)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isEditingQuestion ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteQuestion(index)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete question"
|
||||
>
|
||||
<MdDelete size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{extra && <div className="mt-4">{extra}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-stretch gap-4">
|
||||
<div className='flex flex-col flex-none w-12'>
|
||||
<div className="flex-none">
|
||||
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
|
||||
</div>
|
||||
<div className='flex-1 flex items-center justify-center group'>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors"
|
||||
>
|
||||
<MdDragIndicator size={24} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{children}
|
||||
</div>
|
||||
<div className={clsx('flex flex-col gap-4', variant !== "del-up" ? "justify-center": "mt-1.5")}>
|
||||
<button
|
||||
onClick={() => deleteQuestion(index)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete question"
|
||||
>
|
||||
<MdDelete size={variant !== "del-up" ? 20 : 24} />
|
||||
</button>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableQuestion;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AlertItem } from "./Alert";
|
||||
|
||||
|
||||
const setEditingAlert = (editing: boolean, setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>) => {
|
||||
if (editing) {
|
||||
setAlerts(prev => {
|
||||
if (!prev.some(alert => alert.variant === "info")) {
|
||||
return [...prev, {
|
||||
variant: "info",
|
||||
description: "You have unsaved changes. Don't forget to save your work!",
|
||||
tag: "editing"
|
||||
}];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else {
|
||||
setAlerts([]);
|
||||
}
|
||||
}
|
||||
|
||||
export default setEditingAlert;
|
||||
177
src/components/ExamEditor/Exercises/Speaking/index.tsx
Normal file
177
src/components/ExamEditor/Exercises/Speaking/index.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { ModuleState } from "@/stores/examEditor/types";
|
||||
import { SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import Header from "../../Shared/Header";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: SpeakingExercise | InteractiveSpeakingExercise;
|
||||
}
|
||||
|
||||
const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const { generating, genResult } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [loading, setLoading] = useState(generating === "context");
|
||||
const [questions, setQuestions] = useState(() => {
|
||||
if (sectionId === 1) {
|
||||
return (exercise as SpeakingExercise).prompts || Array(5).fill("");
|
||||
} else if (sectionId === 2) {
|
||||
return [(exercise as SpeakingExercise).text || "", ...(exercise as SpeakingExercise).prompts || Array(3).fill("")];
|
||||
} else {
|
||||
return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text) || Array(5).fill("");
|
||||
}
|
||||
});
|
||||
|
||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||
}, [dispatch]);
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
let newExercise;
|
||||
if (sectionId === 1) {
|
||||
newExercise = {
|
||||
...local,
|
||||
prompts: questions
|
||||
} as SpeakingExercise;
|
||||
} else if (sectionId === 2) {
|
||||
newExercise = {
|
||||
...local,
|
||||
text: questions[0],
|
||||
prompts: questions.slice(1),
|
||||
} as SpeakingExercise;
|
||||
} else {
|
||||
// Section 3
|
||||
newExercise = {
|
||||
...local,
|
||||
prompts: questions.map(text => ({
|
||||
text,
|
||||
video_url: (local as InteractiveSpeakingExercise).prompts?.[0]?.video_url || ""
|
||||
}))
|
||||
} as InteractiveSpeakingExercise;
|
||||
}
|
||||
setEditing(false);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId: sectionId,
|
||||
update: newExercise
|
||||
}
|
||||
});
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
if (sectionId === 1) {
|
||||
setQuestions((exercise as SpeakingExercise).prompts || Array(5).fill(""));
|
||||
} else if (sectionId === 2) {
|
||||
setQuestions([(exercise as SpeakingExercise).text || "", ...(exercise as SpeakingExercise).prompts || Array(3).fill("")]);
|
||||
} else {
|
||||
setQuestions((exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text) || Array(5).fill(""));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const isLoading = generating === "context";
|
||||
setLoading(isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
updateModule({ edit: Array.from(new Set([...edit, sectionId])) });
|
||||
}
|
||||
}, [generating, sectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "context") {
|
||||
setEditing(true);
|
||||
if (sectionId === 1) {
|
||||
setQuestions(genResult[0].questions);
|
||||
} else if (sectionId === 2) {
|
||||
setQuestions([genResult[0].question, ...genResult[0].prompts]);
|
||||
} else {
|
||||
setQuestions(genResult[0].questions);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: currentModule,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [genResult, generating, dispatch, sectionId, setEditing, currentModule]);
|
||||
|
||||
const handleQuestionChange = (index: number, value: string) => {
|
||||
setQuestions(prev => {
|
||||
const newQuestions = [...prev];
|
||||
newQuestions[index] = value;
|
||||
return newQuestions;
|
||||
});
|
||||
};
|
||||
|
||||
const getQuestionLabel = (index: number) => {
|
||||
if (sectionId === 2 && index === 0) {
|
||||
return "Main Question";
|
||||
} else if (sectionId === 2) {
|
||||
return `Prompt ${index}`;
|
||||
} else {
|
||||
return `Question ${index + 1}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Speaking ${sectionId} Script`}
|
||||
description='Generate or write the script for the video.'
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<GenLoader module={currentModule} />
|
||||
) : (
|
||||
<div className="mx-auto p-3 space-y-6">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{questions.map((question: string, index: number) => (
|
||||
<div key={index} className="flex flex-col">
|
||||
<h2 className="font-semibold my-2">{getQuestionLabel(index)}</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={question}
|
||||
onChange={(text) => handleQuestionChange(index, text)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder={`Enter ${getQuestionLabel(index).toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Speaking;
|
||||
235
src/components/ExamEditor/Exercises/TrueFalse/index.tsx
Normal file
235
src/components/ExamEditor/Exercises/TrueFalse/index.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
MdAdd,
|
||||
MdEdit,
|
||||
MdEditOff,
|
||||
} from 'react-icons/md';
|
||||
import Alert, { AlertItem } from '../Shared/Alert';
|
||||
import { ReadingPart, TrueFalseExercise } from '@/interfaces/exam';
|
||||
import QuestionsList from '../Shared/QuestionsList';
|
||||
import Header from '../../Shared/Header';
|
||||
import SortableQuestion from '../Shared/SortableQuestion';
|
||||
import clsx from 'clsx';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import useSectionEdit from '../../Hooks/useSectionEdit';
|
||||
import { toast } from 'react-toastify';
|
||||
import validateTrueFalseQuestions from './validation';
|
||||
import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
|
||||
|
||||
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, 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 [editingPrompt, setEditingPrompt] = useState(false);
|
||||
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
const updateLocal = (exercise: TrueFalseExercise) => {
|
||||
setLocal(exercise);
|
||||
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 addQuestion = () => {
|
||||
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
|
||||
updateLocal({
|
||||
...local,
|
||||
questions: [
|
||||
...local.questions,
|
||||
{
|
||||
prompt: "",
|
||||
solution: undefined,
|
||||
id: newId
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
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 = validateTrueFalseQuestions(
|
||||
local.questions,
|
||||
setAlerts
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: { globalEdit: globalEdit.filter(id => id !== sectionId) } } });
|
||||
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(() => {
|
||||
validateTrueFalseQuestions(local.questions, setAlerts);
|
||||
}, [local.questions]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setEditing(true);
|
||||
setLocal(handleTrueFalseReorder(event, local));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title='True/False/Not Given Exercise'
|
||||
description='Edit questions and their solutions'
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{editingPrompt ? (
|
||||
<textarea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={local.prompt}
|
||||
onChange={(e) => updateLocal({ ...local, prompt: e.target.value })}
|
||||
onBlur={() => setEditingPrompt(false)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||
<p className="text-gray-600">{local.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editingPrompt ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
>
|
||||
{local.questions.map((question, index) => (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={index}
|
||||
deleteQuestion={deleteQuestion}
|
||||
>
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={question.prompt}
|
||||
onChange={(e) => updateQuestion(index, 'prompt', e.target.value)}
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Enter question..."
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
{['true', 'false', 'not_given'].map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-3 text-center rounded-lg border-2 transition-all flex items-center justify-center gap-2",
|
||||
question.solution === value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${question.id}`}
|
||||
value={value}
|
||||
checked={question.solution === value}
|
||||
onChange={(e) => updateQuestion(index, 'solution', e.target.value)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 sr-only"
|
||||
/>
|
||||
<span>
|
||||
{value.replace('_', ' ').charAt(0).toUpperCase() + value.slice(1).replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrueFalse;
|
||||
46
src/components/ExamEditor/Exercises/TrueFalse/validation.ts
Normal file
46
src/components/ExamEditor/Exercises/TrueFalse/validation.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AlertItem } from "../Shared/Alert";
|
||||
|
||||
const validateTrueFalseQuestions = (
|
||||
questions: {
|
||||
id: string;
|
||||
prompt: string;
|
||||
solution?: string;
|
||||
}[],
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
let hasErrors = false;
|
||||
|
||||
const emptyPrompts = questions.filter(q => !q.prompt.trim());
|
||||
if (emptyPrompts.length > 0) {
|
||||
hasErrors = true;
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-prompt'));
|
||||
return [...filteredAlerts, ...emptyPrompts.map(q => ({
|
||||
variant: "error" as const,
|
||||
tag: `empty-prompt-${q.id}`,
|
||||
description: `Question ${q.id} has an empty prompt`
|
||||
}))];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-prompt')));
|
||||
}
|
||||
|
||||
const missingSolutions = questions.filter(q => q.solution === undefined);
|
||||
if (missingSolutions.length > 0) {
|
||||
hasErrors = true;
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('missing-solution'));
|
||||
return [...filteredAlerts, ...missingSolutions.map(q => ({
|
||||
variant: "error" as const,
|
||||
tag: `missing-solution-${q.id}`,
|
||||
description: `Question ${q.id} is missing a solution`
|
||||
}))];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('missing-solution')));
|
||||
}
|
||||
|
||||
return !hasErrors;
|
||||
};
|
||||
|
||||
export default validateTrueFalseQuestions;
|
||||
341
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
341
src/components/ExamEditor/Exercises/WriteBlanks/index.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
MdAdd,
|
||||
MdEdit,
|
||||
MdEditOff,
|
||||
MdDelete,
|
||||
} from 'react-icons/md';
|
||||
import QuestionsList from '../Shared/QuestionsList';
|
||||
import SortableQuestion from '../Shared/SortableQuestion';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import Header from '../../Shared/Header';
|
||||
import clsx from 'clsx';
|
||||
import Alert, { AlertItem } from '../Shared/Alert';
|
||||
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
|
||||
import { ReadingPart, WriteBlanksExercise } from '@/interfaces/exam';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import useSectionEdit from '../../Hooks/useSectionEdit';
|
||||
import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { toast } from 'react-toastify';
|
||||
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
|
||||
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
||||
|
||||
|
||||
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||
|
||||
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 [editingPrompt, setEditingPrompt] = useState(false);
|
||||
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||
|
||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
const isQuestionTextValid = validateQuestionText(
|
||||
parsedQuestions,
|
||||
setAlerts
|
||||
);
|
||||
|
||||
const isSolutionsValid = validateEmptySolutions(
|
||||
local.solutions,
|
||||
setAlerts
|
||||
);
|
||||
|
||||
if (!isQuestionTextValid || !isSolutionsValid) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: {globalEdit: globalEdit.filter(id => id !== sectionId)} } });
|
||||
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||
},
|
||||
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 } });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setParsedQuestions(parseText(local.text));
|
||||
}, [local.text]);
|
||||
|
||||
const updateLocal = (exercise: WriteBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const addQuestion = () => {
|
||||
const existingIds = parsedQuestions.map(q => parseInt(q.id));
|
||||
const newId = (Math.max(...existingIds, 0) + 1).toString();
|
||||
|
||||
const newQuestion = {
|
||||
id: newId,
|
||||
questionText: "New question"
|
||||
};
|
||||
|
||||
const updatedQuestions = [...parsedQuestions, newQuestion];
|
||||
const updatedText = reconstructText(updatedQuestions);
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
id: newId,
|
||||
solution: [""]
|
||||
}];
|
||||
|
||||
updateLocal({
|
||||
...local,
|
||||
text: updatedText,
|
||||
solutions: updatedSolutions
|
||||
});
|
||||
};
|
||||
|
||||
const updateQuestionText = (id: string, newText: string) => {
|
||||
const updatedQuestions = parsedQuestions.map(q =>
|
||||
q.id === id ? { ...q, questionText: newText } : q
|
||||
);
|
||||
const updatedText = reconstructText(updatedQuestions);
|
||||
updateLocal({ ...local, text: updatedText });
|
||||
};
|
||||
|
||||
const deleteQuestion = (id: string) => {
|
||||
if (parsedQuestions.length == 1) {
|
||||
toast.error("There needs to be at least one question!");
|
||||
return;
|
||||
}
|
||||
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
|
||||
const updatedText = reconstructText(updatedQuestions);
|
||||
const updatedSolutions = local.solutions.filter(s => s.id !== id);
|
||||
updateLocal({
|
||||
...local,
|
||||
text: updatedText,
|
||||
solutions: updatedSolutions
|
||||
});
|
||||
};
|
||||
|
||||
const addSolutionToQuestion = (questionId: string) => {
|
||||
const newSolutions = [...local.solutions];
|
||||
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||
|
||||
if (questionIndex !== -1) {
|
||||
newSolutions[questionIndex] = {
|
||||
...newSolutions[questionIndex],
|
||||
solution: [...newSolutions[questionIndex].solution, ""]
|
||||
};
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
}
|
||||
};
|
||||
|
||||
const updateSolution = (questionId: string, solutionIndex: number, value: string) => {
|
||||
const wordCount = value.trim().split(/\s+/).length;
|
||||
|
||||
const newSolutions = [...local.solutions];
|
||||
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||
|
||||
if (questionIndex !== -1) {
|
||||
const newSolutionArray = [...newSolutions[questionIndex].solution];
|
||||
newSolutionArray[solutionIndex] = value;
|
||||
newSolutions[questionIndex] = {
|
||||
...newSolutions[questionIndex],
|
||||
solution: newSolutionArray
|
||||
};
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
}
|
||||
|
||||
if (wordCount > local.maxWords) {
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`);
|
||||
return [...filteredAlerts, {
|
||||
variant: "error",
|
||||
tag: `solution-error-${questionId}-${solutionIndex}`,
|
||||
description: `Alternative solution ${solutionIndex + 1} for question ${questionId} exceeds maximum of ${local.maxWords} words (current: ${wordCount} words)`
|
||||
}];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSolution = (questionId: string, solutionIndex: number) => {
|
||||
const newSolutions = [...local.solutions];
|
||||
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
|
||||
|
||||
if (questionIndex !== -1) {
|
||||
if (newSolutions[questionIndex].solution.length == 1) {
|
||||
toast.error("There needs to be at least one solution!");
|
||||
return;
|
||||
}
|
||||
const newSolutionArray = newSolutions[questionIndex].solution.filter((_, i) => i !== solutionIndex);
|
||||
newSolutions[questionIndex] = {
|
||||
...newSolutions[questionIndex],
|
||||
solution: newSolutionArray
|
||||
};
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setEditing(true);
|
||||
setLocal(handleWriteBlanksReorder(event, local));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
validateWordCount(local.solutions, local.maxWords, setAlerts);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [local.maxWords, local.solutions]);
|
||||
|
||||
useEffect(() => {
|
||||
validateQuestionText(parsedQuestions, setAlerts);
|
||||
}, [parsedQuestions]);
|
||||
|
||||
useEffect(() => {
|
||||
validateEmptySolutions(local.solutions, setAlerts);
|
||||
}, [local.solutions]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title="Write Blanks Exercise"
|
||||
description="Edit questions and their solutions"
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
handleDiscard={handleDiscard}
|
||||
modeHandle={modeHandle}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex justify-between items-start gap-4 mb-6">
|
||||
{editingPrompt ? (
|
||||
<AutoExpandingTextArea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={local.prompt}
|
||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
||||
onBlur={() => setEditingPrompt(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions displayed to the student:</h3>
|
||||
<p className="text-gray-600">{local.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editingPrompt ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800">Maximum words per solution:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={local.maxWords}
|
||||
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
|
||||
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
min="1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={parsedQuestions.map(q => q.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
>
|
||||
{parsedQuestions.map((question) => {
|
||||
const questionSolutions = local.solutions.find(s => s.id === question.id)?.solution || [];
|
||||
return (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={parseInt(question.id)}
|
||||
deleteQuestion={() => deleteQuestion(question.id)}
|
||||
variant="writeBlanks"
|
||||
questionText={question.questionText}
|
||||
onQuestionChange={(value) => updateQuestionText(question.id, value)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{questionSolutions.map((solution, solutionIndex) => (
|
||||
<div key={solutionIndex} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={solution}
|
||||
onChange={(e) => updateSolution(question.id, solutionIndex, e.target.value)}
|
||||
className={clsx(
|
||||
"flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none",
|
||||
errors[question.id]?.[solutionIndex] && "border-red-500"
|
||||
)}
|
||||
placeholder="Enter solution..."
|
||||
/>
|
||||
<button
|
||||
onClick={() => deleteSolution(question.id, solutionIndex)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<MdDelete size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addSolutionToQuestion(question.id)}
|
||||
className="w-full p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add Alternative Solution
|
||||
</button>
|
||||
</div>
|
||||
</SortableQuestion>
|
||||
);
|
||||
})}
|
||||
</QuestionsList>
|
||||
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriteBlanks;
|
||||
27
src/components/ExamEditor/Exercises/WriteBlanks/parsing.ts
Normal file
27
src/components/ExamEditor/Exercises/WriteBlanks/parsing.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface ParsedQuestion {
|
||||
id: string;
|
||||
questionText: string;
|
||||
}
|
||||
|
||||
const parseText = (text: string): ParsedQuestion[] => {
|
||||
const lines = text.split('\\n').filter(line => line.trim());
|
||||
return lines.map(line => {
|
||||
const match = line.match(/(.*?)\{\{(\d+)\}\}/);
|
||||
if (match) {
|
||||
return {
|
||||
questionText: match[1],
|
||||
id: match[2]
|
||||
};
|
||||
}
|
||||
return { questionText: line, id: '' };
|
||||
}).filter(q => q.id);
|
||||
};
|
||||
|
||||
const reconstructText = (questions: ParsedQuestion[]): string => {
|
||||
return questions.map(q => `${q.questionText}{{${q.id}}}`).join('\\n') + '\\n';
|
||||
};
|
||||
|
||||
export {
|
||||
parseText,
|
||||
reconstructText
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { AlertItem } from "../Shared/Alert";
|
||||
import { ParsedQuestion } from "./parsing";
|
||||
|
||||
export const validateQuestionText = (
|
||||
parsedQuestions: ParsedQuestion[],
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
const unmodifiedQuestions = parsedQuestions.filter(q => q.questionText === "New question");
|
||||
if (unmodifiedQuestions.length > 0) {
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmodified-question'));
|
||||
return [...filteredAlerts, ...unmodifiedQuestions.map(q => ({
|
||||
variant: "error" as const,
|
||||
tag: `unmodified-question-${q.id}`,
|
||||
description: `Question ${q.id} is unmodified`
|
||||
}))];
|
||||
});
|
||||
return false;
|
||||
}
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmodified-question')));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const validateEmptySolutions = (
|
||||
solutions: Array<{ id: string; solution: string[] }>,
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
const questionsWithEmptySolutions = solutions.flatMap(solution =>
|
||||
solution.solution.map((sol, index) => ({
|
||||
questionId: solution.id,
|
||||
solutionIndex: index,
|
||||
isEmpty: !sol.trim()
|
||||
})).filter(({ isEmpty }) => isEmpty)
|
||||
);
|
||||
|
||||
if (questionsWithEmptySolutions.length > 0) {
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
|
||||
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
|
||||
variant: "error" as const,
|
||||
tag: `empty-solution-${questionId}-${solutionIndex}`,
|
||||
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
|
||||
}))];
|
||||
});
|
||||
return false;
|
||||
}
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const validateWordCount = (
|
||||
solutions: Array<{ id: string; solution: string[] }>,
|
||||
maxWords: number,
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
solutions.forEach((solution) => {
|
||||
solution.solution.forEach((value, solutionIndex) => {
|
||||
const wordCount = value.trim().split(/\s+/).length;
|
||||
if (wordCount > maxWords) {
|
||||
isValid = false;
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert =>
|
||||
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||
);
|
||||
return [...filteredAlerts, {
|
||||
variant: "error",
|
||||
tag: `solution-error-${solution.id}-${solutionIndex}`,
|
||||
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
|
||||
}];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev =>
|
||||
prev.filter(alert =>
|
||||
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return isValid;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useSensors, useSensor, PointerSensor, KeyboardSensor, DragEndEvent, DndContext, closestCenter } from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates, arrayMove, SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useState } from "react";
|
||||
import { BsCursorText } from "react-icons/bs";
|
||||
import { MdSpaceBar } from "react-icons/md";
|
||||
import { toast } from "react-toastify";
|
||||
import { formatDisplayContent, formatStorageContent, PromptPart, reconstructLine } from "./parsing";
|
||||
import SortableBlank from "./SortableBlank";
|
||||
import { validatePlaceholders } from "./validation";
|
||||
|
||||
interface Props {
|
||||
parts: PromptPart[];
|
||||
onUpdate: (newText: string) => void;
|
||||
}
|
||||
|
||||
interface EditingState {
|
||||
text: string;
|
||||
isPlaceholderMode: boolean;
|
||||
}
|
||||
|
||||
|
||||
const BlanksFormEditor: React.FC<Props> = ({ parts, onUpdate }) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const [editingState, setEditingState] = useState<EditingState>({
|
||||
text: formatDisplayContent(reconstructLine(parts)),
|
||||
isPlaceholderMode: true
|
||||
});
|
||||
|
||||
const handleTextChange = (newText: string) => {
|
||||
const placeholder = parts.find(p => p.isPlaceholder);
|
||||
if (!placeholder) return;
|
||||
|
||||
const displayPlaceholder = formatDisplayContent(placeholder.content);
|
||||
|
||||
if (!newText.includes(displayPlaceholder)) {
|
||||
const placeholderIndex = editingState.text.indexOf(displayPlaceholder);
|
||||
|
||||
if (placeholderIndex >= 0) {
|
||||
const beforePlaceholder = newText.slice(0, Math.min(placeholderIndex, newText.length));
|
||||
const afterPlaceholder = newText.slice(Math.min(placeholderIndex, newText.length));
|
||||
newText = beforePlaceholder + displayPlaceholder + afterPlaceholder;
|
||||
} else {
|
||||
newText = newText + ' ' + displayPlaceholder;
|
||||
}
|
||||
}
|
||||
|
||||
setEditingState(prev => ({
|
||||
...prev,
|
||||
text: newText
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = parts.findIndex(part => part.id === active.id);
|
||||
const newIndex = parts.findIndex(part => part.id === over.id);
|
||||
|
||||
const newParts = [...parts];
|
||||
const [movedPart] = newParts.splice(oldIndex, 1);
|
||||
newParts.splice(newIndex, 0, movedPart);
|
||||
|
||||
onUpdate(reconstructLine(newParts));
|
||||
|
||||
setEditingState(prev => ({
|
||||
...prev,
|
||||
text: formatDisplayContent(reconstructLine(newParts))
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleEditMode = () => {
|
||||
setEditingState(prev => ({
|
||||
...prev,
|
||||
isPlaceholderMode: !prev.isPlaceholderMode
|
||||
}));
|
||||
};
|
||||
|
||||
const saveTextChanges = () => {
|
||||
const placeholderId = parts.find(p => p.isPlaceholder)?.id;
|
||||
if (!placeholderId) return;
|
||||
|
||||
const validation = validatePlaceholders(editingState.text, placeholderId);
|
||||
if (!validation.isValid) {
|
||||
toast.error(validation.message);
|
||||
setEditingState(prev => ({
|
||||
...prev,
|
||||
text: formatDisplayContent(reconstructLine(parts))
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate(formatStorageContent(editingState.text));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex-grow">
|
||||
{editingState.isPlaceholderMode ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={parts.map(part => part.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 min-h-[40px] p-2 border rounded-lg bg-white">
|
||||
{parts.map((part) => (
|
||||
<SortableBlank
|
||||
key={part.id}
|
||||
id={part.id}
|
||||
isPlaceholder={part.isPlaceholder}
|
||||
>
|
||||
{part.isPlaceholder ? (
|
||||
<div className="bg-blue-200 px-2 py-1 rounded cursor-move">
|
||||
{formatDisplayContent(part.content)}
|
||||
</div>
|
||||
) : /^\s+$/.test(part.content) ? (
|
||||
<div className="px-1 border-l-2 border-r-2 border-transparent">
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-1">
|
||||
{part.content}
|
||||
</div>
|
||||
)}
|
||||
</SortableBlank>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={editingState.text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onPaste={(e) => e.preventDefault()}
|
||||
onBlur={saveTextChanges}
|
||||
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`p-2 rounded ${editingState.isPlaceholderMode ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
|
||||
onClick={toggleEditMode}
|
||||
title={editingState.isPlaceholderMode ? "Switch to text editing" : "Switch to placeholder editing"}
|
||||
>
|
||||
{editingState.isPlaceholderMode ? <BsCursorText size={20} /> : <MdSpaceBar size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlanksFormEditor;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
interface SortableBlankProps {
|
||||
id: string;
|
||||
isPlaceholder?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SortableBlank: React.FC<SortableBlankProps> = ({ id, isPlaceholder, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
cursor: isPlaceholder ? 'move' : 'default',
|
||||
};
|
||||
|
||||
const draggableProps = isPlaceholder ? { ...attributes, ...listeners } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...draggableProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableBlank;
|
||||
318
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
318
src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { WriteBlanksExercise, ReadingPart } from "@/interfaces/exam";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { DragEndEvent } from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MdEditOff, MdEdit, MdDelete, MdAdd } from "react-icons/md";
|
||||
import { toast } from "react-toastify";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import Alert, { AlertItem } from "../Shared/Alert";
|
||||
import QuestionsList from "../Shared/QuestionsList";
|
||||
import setEditingAlert from "../Shared/setEditingAlert";
|
||||
import SortableQuestion from "../Shared/SortableQuestion";
|
||||
import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
|
||||
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
|
||||
import Header from "../../Shared/Header";
|
||||
import BlanksFormEditor from "./BlanksFormEditor";
|
||||
|
||||
|
||||
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
|
||||
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 [editingPrompt, setEditingPrompt] = useState(false);
|
||||
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||
|
||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
|
||||
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
|
||||
|
||||
if (!isQuestionsValid || !isSolutionsValid) {
|
||||
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 } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
setParsedQuestions([]);
|
||||
},
|
||||
onMode: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const questions = local.text.split('\\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => {
|
||||
const match = line.match(/{{(\d+)}}/);
|
||||
return {
|
||||
id: match ? match[1] : `unknown-${Date.now()}`,
|
||||
parts: parseLine(line),
|
||||
editingPlaceholders: true
|
||||
};
|
||||
});
|
||||
setParsedQuestions(questions);
|
||||
}, [local.text]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
validateWordCount(local.solutions, local.maxWords, setAlerts);
|
||||
}, [local.maxWords, local.solutions]);
|
||||
|
||||
const updateLocal = (exercise: WriteBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const addQuestion = () => {
|
||||
const existingIds = parsedQuestions.map(q => parseInt(q.id));
|
||||
const newId = (Math.max(...existingIds, 0) + 1).toString();
|
||||
|
||||
const newLine = `New question with blank {{${newId}}}`;
|
||||
const updatedQuestions = [...parsedQuestions, {
|
||||
id: newId,
|
||||
parts: parseLine(newLine),
|
||||
editingPlaceholders: true
|
||||
}];
|
||||
|
||||
const newText = updatedQuestions
|
||||
.map(q => reconstructLine(q.parts))
|
||||
.join('\\n') + '\\n';
|
||||
|
||||
const updatedSolutions = [...local.solutions, {
|
||||
id: newId,
|
||||
solution: [""]
|
||||
}];
|
||||
|
||||
updateLocal({
|
||||
...local,
|
||||
text: newText,
|
||||
solutions: updatedSolutions
|
||||
});
|
||||
};
|
||||
|
||||
const deleteQuestion = (id: string) => {
|
||||
if (parsedQuestions.length === 1) {
|
||||
toast.error("There needs to be at least one question!");
|
||||
return;
|
||||
}
|
||||
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
|
||||
const newText = updatedQuestions
|
||||
.map(q => reconstructLine(q.parts))
|
||||
.join('\\n') + '\\n';
|
||||
|
||||
const updatedSolutions = local.solutions.filter(s => s.id !== id);
|
||||
updateLocal({
|
||||
...local,
|
||||
text: newText,
|
||||
solutions: updatedSolutions
|
||||
});
|
||||
};
|
||||
|
||||
const handleQuestionUpdate = (questionId: string, newText: string) => {
|
||||
const updatedQuestions = parsedQuestions.map(q =>
|
||||
q.id === questionId ? { ...q, parts: parseLine(newText) } : q
|
||||
);
|
||||
|
||||
const updatedText = updatedQuestions
|
||||
.map(q => reconstructLine(q.parts))
|
||||
.join('\\n') + '\\n';
|
||||
|
||||
updateLocal({ ...local, text: updatedText });
|
||||
};
|
||||
|
||||
const addSolution = (questionId: string) => {
|
||||
const newSolutions = local.solutions.map(s =>
|
||||
s.id === questionId
|
||||
? { ...s, solution: [...s.solution, ""] }
|
||||
: s
|
||||
);
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
};
|
||||
|
||||
const updateSolution = (questionId: string, index: number, value: string) => {
|
||||
const newSolutions = local.solutions.map(s =>
|
||||
s.id === questionId
|
||||
? { ...s, solution: s.solution.map((sol, i) => i === index ? value : sol) }
|
||||
: s
|
||||
);
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
};
|
||||
|
||||
const deleteSolution = (questionId: string, index: number) => {
|
||||
const solutions = local.solutions.find(s => s.id === questionId);
|
||||
if (solutions && solutions.solution.length <= 1) {
|
||||
toast.error("Each question must have at least one solution!");
|
||||
return;
|
||||
}
|
||||
const newSolutions = local.solutions.map(s =>
|
||||
s.id === questionId
|
||||
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
|
||||
: s
|
||||
);
|
||||
updateLocal({ ...local, solutions: newSolutions });
|
||||
};
|
||||
|
||||
const handleQuestionsReorder = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = parsedQuestions.findIndex(q => q.id === active.id);
|
||||
const newIndex = parsedQuestions.findIndex(q => q.id === over.id);
|
||||
|
||||
const reorderedQuestions = arrayMove(parsedQuestions, oldIndex, newIndex);
|
||||
const newText = reorderedQuestions
|
||||
.map(q => reconstructLine(q.parts))
|
||||
.join('\\n') + '\\n';
|
||||
|
||||
updateLocal({ ...local, text: newText });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title="Write Blanks: Form Exercise"
|
||||
description="Edit questions and their solutions"
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
handleDiscard={handleDiscard}
|
||||
modeHandle={modeHandle}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex justify-between items-start gap-4 mb-6">
|
||||
{editingPrompt ? (
|
||||
<AutoExpandingTextArea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={local.prompt}
|
||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
||||
onBlur={() => setEditingPrompt(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">Question/Instructions:</h3>
|
||||
<p className="text-gray-600">{local.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditingPrompt(!editingPrompt)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editingPrompt ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800">Maximum words per solution:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={local.maxWords}
|
||||
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
|
||||
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
min="1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={parsedQuestions.map(q => q.id)}
|
||||
handleDragEnd={handleQuestionsReorder}
|
||||
>
|
||||
{parsedQuestions.map((question, index) => (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={index}
|
||||
deleteQuestion={() => deleteQuestion(question.id)}
|
||||
variant="del-up"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<BlanksFormEditor
|
||||
parts={question.parts}
|
||||
onUpdate={(newText) => handleQuestionUpdate(question.id, newText)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">Solutions:</h4>
|
||||
{local.solutions.find(s => s.id === question.id)?.solution.map((solution, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={solution}
|
||||
onChange={(e) => updateSolution(question.id, 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}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => deleteSolution(question.id, index)}
|
||||
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete solution"
|
||||
>
|
||||
<MdDelete size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addSolution(question.id)}
|
||||
className="w-full p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add Alternative Solution
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriteBlanksForm;
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface PromptPart {
|
||||
id: string;
|
||||
content: string;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface ParsedQuestion {
|
||||
id: string;
|
||||
parts: PromptPart[];
|
||||
editingPlaceholders: boolean;
|
||||
}
|
||||
|
||||
const parseLine = (line: string): PromptPart[] => {
|
||||
const parts: PromptPart[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /{{(\d+)}}/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
const textBefore = line.slice(lastIndex, match.index);
|
||||
const words = textBefore.split(/(\s+)/).filter(Boolean);
|
||||
words.forEach(word => {
|
||||
parts.push({
|
||||
id: `text-${Date.now()}-${parts.length}`,
|
||||
content: word
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const placeholderId = match[1];
|
||||
parts.push({
|
||||
id: placeholderId,
|
||||
content: match[0],
|
||||
isPlaceholder: true
|
||||
});
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < line.length) {
|
||||
const textAfter = line.slice(lastIndex);
|
||||
const words = textAfter.split(/(\s+)/).filter(Boolean);
|
||||
words.forEach(word => {
|
||||
parts.push({
|
||||
id: `text-${Date.now()}-${parts.length}`,
|
||||
content: word
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const reconstructLine = (parts: PromptPart[]): string => {
|
||||
const text = parts
|
||||
.map(part => part.content)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text;
|
||||
};
|
||||
|
||||
|
||||
const formatDisplayContent = (content: string): string => {
|
||||
return content.replace(/{{(\d+)}}/g, '[$1]');
|
||||
};
|
||||
|
||||
const formatStorageContent = (content: string): string => {
|
||||
return content.replace(/\[(\d+)\]/g, '{{$1}}');
|
||||
};
|
||||
|
||||
export {
|
||||
parseLine,
|
||||
reconstructLine,
|
||||
formatDisplayContent,
|
||||
formatStorageContent
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { AlertItem } from "../Shared/Alert";
|
||||
import { ParsedQuestion, reconstructLine } from "./parsing";
|
||||
|
||||
|
||||
const validatePlaceholders = (text: string, originalId: string): { isValid: boolean; message?: string } => {
|
||||
const matches = text.match(/\[(\d+)\]/g) || [];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "Each question must have exactly one blank"
|
||||
};
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "Only one blank is allowed per question"
|
||||
};
|
||||
}
|
||||
|
||||
const idMatch = matches[0]?.match(/\[(\d+)\]/);
|
||||
if (!idMatch || idMatch[1] !== originalId) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "The blank ID cannot be changed"
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
};
|
||||
|
||||
const validateQuestions = (
|
||||
parsedQuestions: ParsedQuestion[],
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
const emptyQuestions = parsedQuestions.filter(q => reconstructLine(q.parts).trim() === '');
|
||||
if (emptyQuestions.length > 0) {
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-question'));
|
||||
return [...filteredAlerts, ...emptyQuestions.map(q => ({
|
||||
variant: "error" as const,
|
||||
tag: `empty-question-${q.id}`,
|
||||
description: `Question ${q.id} is empty`
|
||||
}))];
|
||||
});
|
||||
return false;
|
||||
}
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-question')));
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateEmptySolutions = (
|
||||
solutions: Array<{ id: string; solution: string[] }>,
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
const questionsWithEmptySolutions = solutions.flatMap(solution =>
|
||||
solution.solution.map((sol, index) => ({
|
||||
questionId: solution.id,
|
||||
solutionIndex: index,
|
||||
isEmpty: !sol.trim()
|
||||
})).filter(({ isEmpty }) => isEmpty)
|
||||
);
|
||||
if (questionsWithEmptySolutions.length > 0) {
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
|
||||
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
|
||||
variant: "error" as const,
|
||||
tag: `empty-solution-${questionId}-${solutionIndex}`,
|
||||
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
|
||||
}))];
|
||||
});
|
||||
return false;
|
||||
}
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateWordCount = (
|
||||
solutions: Array<{ id: string; solution: string[] }>,
|
||||
maxWords: number,
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
let isValid = true;
|
||||
solutions.forEach((solution) => {
|
||||
solution.solution.forEach((value, solutionIndex) => {
|
||||
const wordCount = value.trim().split(/\s+/).length;
|
||||
if (wordCount > maxWords) {
|
||||
isValid = false;
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert =>
|
||||
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||
);
|
||||
return [...filteredAlerts, {
|
||||
variant: "error",
|
||||
tag: `solution-error-${solution.id}-${solutionIndex}`,
|
||||
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
|
||||
}];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev =>
|
||||
prev.filter(alert =>
|
||||
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return isValid;
|
||||
};
|
||||
|
||||
export {
|
||||
validateQuestions,
|
||||
validateEmptySolutions,
|
||||
validateWordCount,
|
||||
validatePlaceholders
|
||||
}
|
||||
119
src/components/ExamEditor/Exercises/Writing/index.tsx
Normal file
119
src/components/ExamEditor/Exercises/Writing/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import ExamEditorStore, { ModuleState } from "@/stores/examEditor/types";
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { WritingExercise } from "@/interfaces/exam";
|
||||
import Header from "../../Shared/Header";
|
||||
import Alert, { AlertItem } from "../Shared/Alert";
|
||||
import clsx from "clsx";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import setEditingAlert from "../Shared/setEditingAlert";
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: WritingExercise;
|
||||
}
|
||||
|
||||
const Writing: React.FC<Props> = ({ sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const {edit } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
const { generating, genResult, state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const exercise = state as WritingExercise;
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [prompt, setPrompt] = useState(exercise.prompt);
|
||||
const [loading, setLoading] = useState(generating && generating == "exercises");
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||
}, [dispatch]);
|
||||
|
||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
const newExercise = { ...local } as WritingExercise;
|
||||
newExercise.prompt = prompt;
|
||||
setAlerts([]);
|
||||
setEditing(false);
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loading = generating && generating == "context";
|
||||
setLoading(loading);
|
||||
|
||||
if (loading) {
|
||||
updateModule({ edit: Array.from(new Set(edit).add(sectionId)) });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [generating, updateModule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult !== undefined && generating === "context") {
|
||||
setEditing(true);
|
||||
setPrompt(genResult[0].prompt);
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined }})
|
||||
}
|
||||
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(state as WritingExercise);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(prompt !== local.prompt, setAlerts);
|
||||
}, [prompt, local.prompt]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Task ${sectionId} Instructions`}
|
||||
description='Generate or edit the instructions for the task'
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
mode="edit"
|
||||
module={"writing"}
|
||||
/>
|
||||
{alerts.length !== 0 && <Alert alerts={alerts} />}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{loading ?
|
||||
<GenLoader module={currentModule} /> :
|
||||
(
|
||||
editing ? (
|
||||
<div className="text-gray-600 p-4">
|
||||
<AutoExpandingTextArea
|
||||
value={prompt}
|
||||
onChange={(text) => setPrompt(text)}
|
||||
placeholder="Instructions ..."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className={
|
||||
clsx("w-full px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line",
|
||||
prompt === "" ? "text-gray-600/50" : "text-gray-600"
|
||||
)
|
||||
}>
|
||||
{prompt === "" ? "Instructions ..." : prompt}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Writing;
|
||||
Reference in New Issue
Block a user