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