Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user