Exam generation rework, batch user tables, fastapi endpoint switch
This commit is contained in:
44
src/components/ExamEditor/Shared/AudioEdit.tsx
Normal file
44
src/components/ExamEditor/Shared/AudioEdit.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface AudioFile {
|
||||
id: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
|
||||
const Waveform = dynamic(() => import("@/components/Waveform"), {ssr: false});
|
||||
|
||||
const MultipleAudioUploader = () => {
|
||||
const [audioFiles, setAudioFiles] = useState<AudioFile[]>([]);
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
const newAudioFiles = files.map((file) => ({
|
||||
id: URL.createObjectURL(file),
|
||||
file,
|
||||
}));
|
||||
setAudioFiles((prev) => [...prev, ...newAudioFiles]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="file" multiple accept="audio/*" onChange={handleFileUpload} />
|
||||
<div className="audio-list">
|
||||
{audioFiles.map((audio) => (
|
||||
<div key={audio.id} className="audio-item">
|
||||
<h3>{audio.file.name}</h3>
|
||||
<Waveform
|
||||
variant='edit'
|
||||
audio={audio.id}
|
||||
waveColor="#ddd"
|
||||
progressColor="#4a90e2"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipleAudioUploader;
|
||||
118
src/components/ExamEditor/Shared/ConfirmDeleteBtn.tsx
Normal file
118
src/components/ExamEditor/Shared/ConfirmDeleteBtn.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MdClose, MdDelete } from 'react-icons/md';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ConfirmDeleteBtnProps {
|
||||
onDelete: () => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ConfirmDeleteBtn: React.FC<ConfirmDeleteBtnProps> = ({
|
||||
onDelete,
|
||||
size = 'sm',
|
||||
position = 'top-right',
|
||||
className
|
||||
}) => {
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setShowConfirm(false);
|
||||
if (showConfirm) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [showConfirm]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirm = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
setShowConfirm(false);
|
||||
};
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowConfirm(false);
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-0.5',
|
||||
md: 'p-1',
|
||||
lg: 'p-1.5'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18
|
||||
};
|
||||
|
||||
const positionClasses = {
|
||||
'top-right': '-right-1 -top-1',
|
||||
'top-left': '-left-1 -top-1',
|
||||
'bottom-right': '-right-1 -bottom-1',
|
||||
'bottom-left': '-left-1 -bottom-1'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute",
|
||||
positionClasses[position],
|
||||
"z-10",
|
||||
className
|
||||
)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{!showConfirm && (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
sizeClasses[size],
|
||||
"rounded-full",
|
||||
"bg-white/90 shadow-sm",
|
||||
"text-gray-400 hover:text-red-600",
|
||||
"transition-all duration-150",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"hover:scale-110"
|
||||
)}
|
||||
title="Remove"
|
||||
>
|
||||
<MdClose size={iconSizes[size]} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showConfirm && (
|
||||
<div className={clsx(
|
||||
"flex items-center gap-1",
|
||||
"bg-white rounded-lg shadow-lg",
|
||||
sizeClasses[size]
|
||||
)}>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="p-1 rounded-md bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
title="Confirm remove"
|
||||
>
|
||||
<MdDelete size={iconSizes[size]} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-1 rounded-md bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<MdClose size={iconSizes[size]} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDeleteBtn;
|
||||
|
||||
15
src/components/ExamEditor/Shared/ExerciseLabel.tsx
Normal file
15
src/components/ExamEditor/Shared/ExerciseLabel.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
interface Props {
|
||||
label: string;
|
||||
preview?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ExerciseLabel: React.FC<Props> = ({label, preview}) => {
|
||||
return (
|
||||
<div className="flex w-full justify-between items-center mr-4">
|
||||
<span className="font-semibold">{label}</span>
|
||||
{preview && <div className="text-sm font-light italic">{preview}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExerciseLabel;
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { ExerciseGen } from './generatedExercises';
|
||||
import Image from 'next/image';
|
||||
import clsx from 'clsx';
|
||||
import { GiBrain } from 'react-icons/gi';
|
||||
import { IoTextOutline } from 'react-icons/io5';
|
||||
import { Switch } from '@headlessui/react';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercises: ExerciseGen[];
|
||||
extraArgs?: Record<string, any>;
|
||||
onSubmit: (configurations: ExerciseConfig[]) => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
export interface ExerciseConfig {
|
||||
type: string;
|
||||
params: {
|
||||
[key: string]: string | number | boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const ExerciseWizard: React.FC<Props> = ({
|
||||
exercises,
|
||||
extraArgs,
|
||||
sectionId,
|
||||
onSubmit,
|
||||
onDiscard,
|
||||
}) => {
|
||||
const {currentModule} = useExamEditorStore();
|
||||
const { selectedExercises } = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId === sectionId))!;
|
||||
|
||||
const [configurations, setConfigurations] = useState<ExerciseConfig[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialConfigs = selectedExercises.map(exerciseType => {
|
||||
const exercise = exercises.find(ex => {
|
||||
const fullType = ex.extra?.find(e => e.param === 'name')?.value
|
||||
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
|
||||
: ex.type;
|
||||
return fullType === exerciseType;
|
||||
});
|
||||
|
||||
const params: { [key: string]: string | number | boolean } = {};
|
||||
exercise?.extra?.forEach(param => {
|
||||
if (param.param !== 'name') {
|
||||
if (exerciseType.includes('paragraphMatch') && param.param === 'quantity') {
|
||||
params[param.param] = extraArgs?.text.split("\n\n").length || 1;
|
||||
} else {
|
||||
params[param.param || ''] = param.value ?? '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
type: exerciseType,
|
||||
params
|
||||
};
|
||||
});
|
||||
|
||||
setConfigurations(initialConfigs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedExercises, exercises]);
|
||||
|
||||
const handleParameterChange = (
|
||||
exerciseIndex: number,
|
||||
paramName: string,
|
||||
value: string | number | boolean
|
||||
) => {
|
||||
setConfigurations(prev => {
|
||||
const newConfigs = [...prev];
|
||||
newConfigs[exerciseIndex] = {
|
||||
...newConfigs[exerciseIndex],
|
||||
params: {
|
||||
...newConfigs[exerciseIndex].params,
|
||||
[paramName]: value
|
||||
}
|
||||
};
|
||||
return newConfigs;
|
||||
});
|
||||
};
|
||||
|
||||
const renderParameterInput = (
|
||||
param: NonNullable<ExerciseGen['extra']>[0],
|
||||
exerciseIndex: number,
|
||||
config: ExerciseConfig
|
||||
) => {
|
||||
if (typeof param.value === 'boolean') {
|
||||
const currentValue = Boolean(config.params[param.param || '']);
|
||||
return (
|
||||
<div className="flex flex-row items-center ml-auto">
|
||||
<GiBrain
|
||||
className="mx-4"
|
||||
size={28}
|
||||
color={currentValue ? `#F3F4F6` : `#1F2937`}
|
||||
/>
|
||||
<Switch
|
||||
checked={currentValue}
|
||||
onChange={(value) => handleParameterChange(
|
||||
exerciseIndex,
|
||||
param.param || '',
|
||||
value
|
||||
)}
|
||||
className={clsx(
|
||||
"relative inline-flex h-[30px] w-[58px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75",
|
||||
currentValue ? `bg-[#F3F4F6]` : `bg-[#1F2937]`
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
"pointer-events-none inline-block h-[26px] w-[26px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||
currentValue ? 'translate-x-7' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<IoTextOutline
|
||||
className="mx-4"
|
||||
size={28}
|
||||
color={!currentValue ? `#F3F4F6` : `#1F2937`}
|
||||
/>
|
||||
|
||||
|
||||
<Tooltip id={`${exerciseIndex}`} className="z-50 bg-white shadow-md rounded-sm" />
|
||||
<a data-tooltip-id={`${exerciseIndex}`} data-tooltip-html="Generate or use placeholder?" className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt={"AI Generated?"} />
|
||||
</a>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputValue = Number(config.params[param.param || '1'].toString());
|
||||
|
||||
const isParagraphMatch = config.type.split("?name=")[1] === "paragraphMatch";
|
||||
const maxParagraphs = isParagraphMatch ? extraArgs!.text.split("\n\n").length : 50;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{`${param.label}${isParagraphMatch ? ` (out of ${extraArgs!.text.split("\n\n").length} paragraphs)` : ""}`}
|
||||
</label>
|
||||
{param.tooltip && (
|
||||
<>
|
||||
<Tooltip id={config.type} className="z-50 bg-white shadow-md rounded-sm" />
|
||||
<a data-tooltip-id={config.type} data-tooltip-html={param.tooltip} className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt={param.tooltip} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => handleParameterChange(
|
||||
exerciseIndex,
|
||||
param.param || '',
|
||||
e.target.value ? Number(e.target.value) : ''
|
||||
)}
|
||||
className="px-3 py-2 shadow-lg rounded-md text-mti-gray-dim w-full"
|
||||
min={1}
|
||||
max={maxParagraphs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderExerciseHeader = (
|
||||
exercise: ExerciseGen,
|
||||
exerciseIndex: number,
|
||||
config: ExerciseConfig,
|
||||
extraParams: boolean,
|
||||
) => {
|
||||
const generateParam = exercise.extra?.find(param => param.param === 'generate');
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center w-full", extraParams ? "mb-4" : "py-4")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<exercise.icon className="h-5 w-5" />
|
||||
<h3 className="font-medium text-lg">{exercise.label}</h3>
|
||||
</div>
|
||||
{generateParam && renderParameterInput(generateParam, exerciseIndex, config)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-4 py-6">
|
||||
{configurations.map((config, exerciseIndex) => {
|
||||
const exercise = exercises.find(ex => {
|
||||
const fullType = ex.extra?.find(e => e.param === 'name')?.value
|
||||
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
|
||||
: ex.type;
|
||||
return fullType === config.type;
|
||||
});
|
||||
|
||||
if (!exercise) return null;
|
||||
|
||||
const nonGenerateParams = exercise.extra?.filter(
|
||||
param => param.param !== 'name' && param.param !== 'generate'
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={config.type}
|
||||
className={`bg-ielts-${currentModule}/70 text-white rounded-lg p-4 shadow-xl`}
|
||||
>
|
||||
{renderExerciseHeader(exercise, exerciseIndex, config, (exercise.extra || []).length > 2)}
|
||||
|
||||
{nonGenerateParams && nonGenerateParams.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{nonGenerateParams.map(param => (
|
||||
<div key={param.param}>
|
||||
{renderParameterInput(param, exerciseIndex, config)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
className={`px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-400 transition-colors`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(configurations)}
|
||||
className={`px-4 py-2 bg-ielts-${currentModule} text-white rounded-md hover:bg-ielts-${currentModule}/80 transition-colors`}
|
||||
>
|
||||
Add Exercises
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExerciseWizard;
|
||||
356
src/components/ExamEditor/Shared/ExercisePicker/exercises.ts
Normal file
356
src/components/ExamEditor/Shared/ExercisePicker/exercises.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
FaListUl,
|
||||
FaUnderline,
|
||||
FaPen,
|
||||
FaBookOpen,
|
||||
FaEnvelope,
|
||||
FaComments,
|
||||
FaHandshake,
|
||||
FaParagraph,
|
||||
FaLightbulb,
|
||||
FaHeadphones,
|
||||
FaWpforms,
|
||||
} from 'react-icons/fa6';
|
||||
|
||||
import {
|
||||
FaEdit,
|
||||
FaFileAlt,
|
||||
FaUserFriends,
|
||||
FaCheckSquare,
|
||||
FaQuestionCircle,
|
||||
} from 'react-icons/fa';
|
||||
import { ExerciseGen } from './generatedExercises';
|
||||
|
||||
const quantity = (quantity: number, tooltip?: string) => {
|
||||
return {
|
||||
param: "quantity",
|
||||
label: "Quantity",
|
||||
tooltip: tooltip ? tooltip : "Exercise Quantity",
|
||||
value: quantity
|
||||
}
|
||||
}
|
||||
|
||||
const generate = () => {
|
||||
return {
|
||||
param: "generate",
|
||||
value: true
|
||||
}
|
||||
}
|
||||
|
||||
const reading = (passage: number) => {
|
||||
const readingExercises = [
|
||||
{
|
||||
label: `Passage ${passage} - Fill Blanks`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaEdit,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "fillBlanks"
|
||||
},
|
||||
{
|
||||
param: "num_random_words",
|
||||
label: "Random Words",
|
||||
tooltip: "Words that are not the solution",
|
||||
value: 1
|
||||
},
|
||||
quantity(4, "Quantity of Blanks"),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
{
|
||||
label: `Passage ${passage} - Write Blanks`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaPen,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanks"
|
||||
},
|
||||
{
|
||||
param: "max_words",
|
||||
label: "Word Limit",
|
||||
tooltip: "How many words a solution can have",
|
||||
value: 3
|
||||
},
|
||||
quantity(4, "Quantity of Blanks"),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
{
|
||||
label: `Passage ${passage} - True False`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaCheckSquare,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "trueFalse"
|
||||
},
|
||||
quantity(4, "Quantity of Statements"),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
{
|
||||
label: `Passage ${passage} - Paragraph Match`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaParagraph,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "paragraphMatch"
|
||||
},
|
||||
quantity(5, "Quantity of Matches"),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
}
|
||||
];
|
||||
|
||||
if (passage === 3) {
|
||||
readingExercises.push(
|
||||
{
|
||||
label: `Passage 3 - Idea Match`,
|
||||
type: `reading_3`,
|
||||
icon: FaLightbulb,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "ideaMatch"
|
||||
},
|
||||
quantity(5, "Quantity of Ideas"),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
);
|
||||
}
|
||||
return readingExercises;
|
||||
}
|
||||
|
||||
const listening = (section: number) => {
|
||||
const listeningExercises = [
|
||||
{
|
||||
label: `Section ${section} - Multiple Choice`,
|
||||
type: `listening_${section}`,
|
||||
icon: FaHeadphones,
|
||||
sectionId: section,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: section == 3 ? "multipleChoice3Options" : "multipleChoice"
|
||||
},
|
||||
quantity(5, "Quantity of Multiple Choice Questions"),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
},
|
||||
{
|
||||
label: `Section ${section} - Write the Blanks: Questions`,
|
||||
type: `listening_${section}`,
|
||||
icon: FaQuestionCircle,
|
||||
sectionId: section,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanksQuestions"
|
||||
},
|
||||
quantity(5, "Quantity of Blanks"),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
}
|
||||
];
|
||||
|
||||
if (section === 1 || section === 4) {
|
||||
listeningExercises.push(
|
||||
{
|
||||
label: `Section ${section} - Write the Blanks: Fill`,
|
||||
type: `listening_${section}`,
|
||||
icon: FaEdit,
|
||||
sectionId: section,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanksFill"
|
||||
},
|
||||
quantity(5, "Quantity of Blanks"),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
}
|
||||
);
|
||||
listeningExercises.push(
|
||||
{
|
||||
label: `Section ${section} - Write the Blanks: Form`,
|
||||
type: `listening_${section}`,
|
||||
sectionId: section,
|
||||
icon: FaWpforms,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanksForm"
|
||||
},
|
||||
quantity(5, "Quantity of Blanks"),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
}
|
||||
);
|
||||
}
|
||||
return listeningExercises;
|
||||
}
|
||||
|
||||
const EXERCISES: ExerciseGen[] = [
|
||||
{
|
||||
label: "Multiple Choice",
|
||||
type: "multipleChoice",
|
||||
icon: FaListUl,
|
||||
extra: [
|
||||
quantity(10, "Amount"),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
{
|
||||
label: "Multiple Choice - Blank Space",
|
||||
type: "mcBlank",
|
||||
icon: FaEdit,
|
||||
extra: [
|
||||
quantity(10, "Amount"),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
{
|
||||
label: "Multiple Choice - Underlined",
|
||||
type: "mcUnderline",
|
||||
icon: FaUnderline,
|
||||
extra: [
|
||||
quantity(10, "Amount"),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
/*{
|
||||
label: "Blank Space", <- Assuming this is FillBlanks aswell
|
||||
type: "blankSpaceText",
|
||||
icon: FaPen,
|
||||
extra: [
|
||||
quantity(10, "Nº of Blanks"),
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
param: "text_size",
|
||||
value: "250"
|
||||
},
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},*/
|
||||
{
|
||||
label: "Fill Blanks: MC",
|
||||
type: "fillBlanksMC",
|
||||
icon: FaPen,
|
||||
extra: [
|
||||
quantity(10, "Nº of Blanks"),
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
param: "text_size",
|
||||
value: "250"
|
||||
},
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
{
|
||||
label: "Reading Passage",
|
||||
type: "passageUtas",
|
||||
icon: FaBookOpen,
|
||||
extra: [
|
||||
// in the utas exam there was only mc so I'm assuming short answers are deprecated
|
||||
/*{
|
||||
label: "Short Answers",
|
||||
param: "sa_qty",
|
||||
value: "10"
|
||||
},*/
|
||||
{
|
||||
label: "Multiple Choice Quantity",
|
||||
param: "mc_qty",
|
||||
value: "10"
|
||||
},
|
||||
{
|
||||
label: "Reading Passage Topic",
|
||||
param: "topic",
|
||||
value: ""
|
||||
},
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
param: "text_size",
|
||||
value: "700"
|
||||
},
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
{
|
||||
label: "Task 1 - Letter",
|
||||
type: "writing_letter",
|
||||
icon: FaEnvelope,
|
||||
extra: [
|
||||
generate()
|
||||
],
|
||||
module: "writing"
|
||||
},
|
||||
{
|
||||
label: "Task 2 - Essay",
|
||||
type: "writing_2",
|
||||
icon: FaFileAlt,
|
||||
extra: [
|
||||
generate()
|
||||
],
|
||||
module: "writing"
|
||||
},
|
||||
{
|
||||
label: "Exercise 1",
|
||||
type: "speaking_1",
|
||||
icon: FaComments,
|
||||
extra: [
|
||||
generate()
|
||||
],
|
||||
module: "speaking"
|
||||
},
|
||||
{
|
||||
label: "Exercise 2",
|
||||
type: "speaking_2",
|
||||
icon: FaUserFriends,
|
||||
extra: [
|
||||
generate()
|
||||
],
|
||||
module: "speaking"
|
||||
},
|
||||
{
|
||||
label: "Interactive",
|
||||
type: "speaking_3",
|
||||
icon: FaHandshake,
|
||||
extra: [
|
||||
generate()
|
||||
],
|
||||
module: "speaking"
|
||||
},
|
||||
...reading(1),
|
||||
...reading(2),
|
||||
...reading(3),
|
||||
...listening(1),
|
||||
...listening(2),
|
||||
...listening(3),
|
||||
...listening(4),
|
||||
]
|
||||
|
||||
export default EXERCISES;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IconType } from "react-icons";
|
||||
|
||||
export interface GeneratedExercises {
|
||||
exercises: Record<string, string>[];
|
||||
sectionId: number;
|
||||
module: string;
|
||||
}
|
||||
|
||||
export interface GeneratorState {
|
||||
loading: boolean;
|
||||
sectionId: number;
|
||||
}
|
||||
|
||||
|
||||
export interface ExerciseGen {
|
||||
label: string;
|
||||
type: string;
|
||||
icon: IconType;
|
||||
sectionId?: number;
|
||||
extra?: { param?: string; value?: string | number | boolean; label?: string; tooltip?: string}[];
|
||||
module: string
|
||||
}
|
||||
173
src/components/ExamEditor/Shared/ExercisePicker/index.tsx
Normal file
173
src/components/ExamEditor/Shared/ExercisePicker/index.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import EXERCISES from "./exercises";
|
||||
import clsx from "clsx";
|
||||
import { ExerciseGen, GeneratedExercises, GeneratorState } from "./generatedExercises";
|
||||
import Modal from "@/components/Modal";
|
||||
import { useState } from "react";
|
||||
import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
|
||||
import { generate } from "../../SettingsEditor/Shared/Generate";
|
||||
import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { Dialog, ListeningPart, ReadingPart } from "@/interfaces/exam";
|
||||
|
||||
interface ExercisePickerProps {
|
||||
module: string;
|
||||
sectionId: number;
|
||||
difficulty: string;
|
||||
extraArgs?: Record<string, any>;
|
||||
}
|
||||
|
||||
const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||
module,
|
||||
sectionId,
|
||||
extraArgs = undefined,
|
||||
}) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const { difficulty, sections } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
const section = sections.find((s) => s.sectionId == sectionId)!;
|
||||
const { state, selectedExercises } = section;
|
||||
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const getFullExerciseType = (exercise: ExerciseGen): string => {
|
||||
if (exercise.extra && exercise.extra.length > 0) {
|
||||
const extraValue = exercise.extra.find(e => e.param === 'name')?.value;
|
||||
return extraValue ? `${exercise.type}/?name=${extraValue}` : exercise.type;
|
||||
}
|
||||
return exercise.type;
|
||||
};
|
||||
|
||||
const handleChange = (exercise: ExerciseGen) => {
|
||||
const fullType = getFullExerciseType(exercise);
|
||||
|
||||
const newSelected = selectedExercises.includes(fullType)
|
||||
? selectedExercises.filter(type => type !== fullType)
|
||||
: [...selectedExercises, fullType];
|
||||
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "selectedExercises", value: newSelected } })
|
||||
};
|
||||
|
||||
const moduleExercises = module === 'level' ? EXERCISES : (sectionId ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module));
|
||||
|
||||
const onModuleSpecific = (configurations: ExerciseConfig[]) => {
|
||||
const exercises = configurations.map(config => {
|
||||
const exerciseType = config.type.split('name=')[1];
|
||||
|
||||
return {
|
||||
type: exerciseType,
|
||||
quantity: Number(config.params.quantity || 1),
|
||||
...(config.params.num_random_words !== undefined && {
|
||||
num_random_words: Number(config.params.num_random_words)
|
||||
}),
|
||||
...(config.params.max_words !== undefined && {
|
||||
max_words: Number(config.params.max_words)
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
let context, moduleState;
|
||||
|
||||
switch (module) {
|
||||
case 'reading':
|
||||
moduleState = state as ReadingPart;
|
||||
context = {
|
||||
text: moduleState.text.content
|
||||
}
|
||||
break;
|
||||
case 'listening':
|
||||
moduleState = state as ListeningPart;
|
||||
let script = moduleState.script;
|
||||
let text, dialog;
|
||||
switch (sectionId) {
|
||||
case 1:
|
||||
case 3:
|
||||
dialog = script as Dialog[];
|
||||
text = dialog.map((d) => `${d.name}: ${d.text}`).join("\n");
|
||||
context = { text: text }
|
||||
break;
|
||||
case 2:
|
||||
case 4:
|
||||
text = script as string;
|
||||
context = { text: text }
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
context = {}
|
||||
}
|
||||
generate(
|
||||
sectionId,
|
||||
module as Module,
|
||||
"exercises",
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
...context,
|
||||
exercises: exercises,
|
||||
difficulty: difficulty
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
exercises: data.exercises
|
||||
}]
|
||||
);
|
||||
|
||||
setPickerOpen(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={pickerOpen} onClose={() => setPickerOpen(false)} title="Exercise Wizard">
|
||||
<ExerciseWizard
|
||||
sectionId={sectionId}
|
||||
exercises={moduleExercises}
|
||||
onSubmit={onModuleSpecific}
|
||||
onDiscard={() => setPickerOpen(false)}
|
||||
extraArgs={extraArgs}
|
||||
/>
|
||||
</Modal>
|
||||
<div className="flex flex-col gap-4 px-4" key={sectionId}>
|
||||
<div className="space-y-2">
|
||||
{moduleExercises.map((exercise) => {
|
||||
const fullType = getFullExerciseType(exercise);
|
||||
return (
|
||||
<label
|
||||
key={fullType}
|
||||
className={`flex items-center space-x-3 text-white font-semibold cursor-pointer p-2 hover:bg-ielts-${exercise.module}/70 rounded bg-ielts-${exercise.module}/90`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="exercise"
|
||||
value={fullType}
|
||||
checked={selectedExercises.includes(fullType)}
|
||||
onChange={() => handleChange(exercise)}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<exercise.icon className="h-5 w-5 text-white" />
|
||||
<span>{exercise.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<button
|
||||
className={
|
||||
clsx("flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 disabled:cursor-not-allowed",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40 `,
|
||||
)
|
||||
}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={selectedExercises.length == 0}
|
||||
>
|
||||
Set Up Exercises ({selectedExercises.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExercisePicker;
|
||||
71
src/components/ExamEditor/Shared/Header.tsx
Normal file
71
src/components/ExamEditor/Shared/Header.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Module } from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave } from "react-icons/md";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
editing: boolean;
|
||||
module?: Module;
|
||||
handleSave: () => void;
|
||||
handleDiscard: () => void;
|
||||
modeHandle?: () => void;
|
||||
mode?: "delete" | "edit";
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const Header: React.FC<Props> = ({ title, description, editing, handleSave, handleDiscard, modeHandle, children, mode = "delete", module }) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{title}</h1>
|
||||
<p className="text-gray-600 mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{children}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editing}
|
||||
className={
|
||||
clsx("px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
|
||||
editing ? 'bg-green-500 text-white hover:bg-green-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<MdSave size={18} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!editing}
|
||||
className={clsx(
|
||||
"px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
|
||||
editing ? 'bg-gray-500 text-white hover:bg-gray-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<MdRefresh size={18} />
|
||||
Discard
|
||||
</button>
|
||||
{mode === "delete" ? (
|
||||
<button
|
||||
onClick={modeHandle}
|
||||
className="px-4 py-2 bg-white border border-red-500 text-red-500 hover:bg-red-50 rounded-lg transition-all duration-200 flex items-center gap-2"
|
||||
>
|
||||
<MdDelete size={18} />
|
||||
Delete
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={modeHandle}
|
||||
className={`px-4 py-2 bg-ielts-${module}/80 text-white hover:bg-ielts-${module} rounded-lg transition-all duration-200 flex items-center gap-2`}
|
||||
>
|
||||
{ editing ? <MdEditOff size={18} /> : <MdEdit size={18} /> }
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { FaPencilAlt } from 'react-icons/fa';
|
||||
import { Module } from '@/interfaces';
|
||||
import clsx from 'clsx';
|
||||
import WordUploader from './WordUploader';
|
||||
import GenLoader from '../../Exercises/Shared/GenLoader';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
|
||||
const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const { importing } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const handleClick = () => {
|
||||
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importModule: false } } });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{importing ? (
|
||||
<GenLoader module={module} custom={`Importing ${module} exam ...`} className='flex flex-grow justify-center bg-slate-200 ' />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 w-full flex-1 gap-6">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
"flex flex-col items-center flex-1 gap-6 justify-center p-8",
|
||||
"border-2 border-gray-200 rounded-xl",
|
||||
`bg-ielts-${module}/20 hover:bg-ielts-${module}/30`,
|
||||
"transition-all duration-300",
|
||||
"shadow-sm hover:shadow-md group")}
|
||||
>
|
||||
<div className="transform group-hover:scale-105 transition-transform duration-300">
|
||||
<FaPencilAlt className={clsx("w-20 h-20 transition-colors duration-300",
|
||||
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
|
||||
module === "listening" && "text-purple-800 group-hover:text-purple-950",
|
||||
module === "level" && "text-teal-700 group-hover:text-teal-900"
|
||||
)} />
|
||||
</div>
|
||||
<span className={clsx("text-lg font-bold transition-colors duration-300",
|
||||
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
|
||||
module === "listening" && "text-purple-800 group-hover:text-purple-950",
|
||||
module === "level" && "text-teal-700 group-hover:text-teal-900"
|
||||
)}>
|
||||
Start from Scratch
|
||||
</span>
|
||||
</button>
|
||||
<div className='h-full'>
|
||||
<WordUploader module={module} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportOrFromScratch;
|
||||
272
src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx
Normal file
272
src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import clsx from 'clsx';
|
||||
import { FaFileUpload, FaCheckCircle, FaLock, FaTimes } from 'react-icons/fa';
|
||||
import { capitalize } from 'lodash';
|
||||
import { Module } from '@/interfaces';
|
||||
import { toast } from 'react-toastify';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import { ReadingPart } from '@/interfaces/exam';
|
||||
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
|
||||
|
||||
const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
||||
const {currentModule, dispatch} = useExamEditorStore();
|
||||
|
||||
const examInputRef = useRef<HTMLInputElement>(null);
|
||||
const solutionsInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showUploaders, setShowUploaders] = useState(false);
|
||||
const [examFile, setExamFile] = useState<File | null>(null);
|
||||
const [solutionsFile, setSolutionsFile] = useState<File | null>(null);
|
||||
|
||||
const handleExamChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type === 'application/msword' ||
|
||||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||
setExamFile(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSolutionsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type === 'application/msword' ||
|
||||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||
setSolutionsFile(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = useCallback( async () => {
|
||||
try {
|
||||
if (!examFile) {
|
||||
toast.error('Exam file is required');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({type: "UPDATE_MODULE", payload: {updates: {importing: true}, module}})
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('exercises', examFile);
|
||||
if (solutionsFile) {
|
||||
formData.append('solutions', solutionsFile);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/exam/${module}/import/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(`An unknown error has occured while import ${module} exam!`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
toast.success(`${capitalize(module)} exam imported successfully!`);
|
||||
|
||||
setExamFile(null);
|
||||
setSolutionsFile(null);
|
||||
setShowUploaders(false);
|
||||
|
||||
switch (currentModule) {
|
||||
case 'reading':
|
||||
const newSectionsStates = data.parts.map(
|
||||
(part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part)
|
||||
);
|
||||
dispatch({type: "UPDATE_MODULE", payload: {
|
||||
updates: {
|
||||
sections: newSectionsStates,
|
||||
minTimer: data.minTimer,
|
||||
importModule: false,
|
||||
importing: false,
|
||||
},
|
||||
module
|
||||
}});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`An unknown error has occured while import ${module} exam!`);
|
||||
} finally {
|
||||
dispatch({type: "UPDATE_MODULE", payload: {updates: {importing: false}, module}})
|
||||
}
|
||||
}, [
|
||||
examFile,
|
||||
solutionsFile,
|
||||
dispatch,
|
||||
module
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!showUploaders ? (
|
||||
<div
|
||||
onClick={() => setShowUploaders(true)}
|
||||
className="flex flex-col items-center gap-6 h-full justify-center p-8 border-2 border-blue-200 rounded-xl
|
||||
bg-gradient-to-b from-blue-50 to-blue-100
|
||||
hover:from-blue-100 hover:to-blue-200
|
||||
cursor-pointer transition-all duration-300
|
||||
shadow-sm hover:shadow-md group"
|
||||
>
|
||||
<div className="transform group-hover:scale-105 transition-transform duration-300">
|
||||
<Image
|
||||
src="/microsoft-word-icon.png"
|
||||
width={200}
|
||||
height={100}
|
||||
alt="Upload Word"
|
||||
className="drop-shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-stone-600 group-hover:text-stone-800 transition-colors duration-300">
|
||||
Upload {capitalize(module)} Exam
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full gap-4 p-6 justify-between border-2 border-blue-200 rounded-xl bg-white shadow-md">
|
||||
<div className='flex flex-col flex-1 justify-center gap-8'>
|
||||
<div
|
||||
onClick={() => examInputRef.current?.click()}
|
||||
className={clsx(
|
||||
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
|
||||
examFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FaFileUpload className={clsx(
|
||||
"w-8 h-8",
|
||||
examFile ? "text-green-500" : "text-gray-400"
|
||||
)} />
|
||||
<div className="flex-grow">
|
||||
<h3 className="font-semibold text-gray-700">Exam Document</h3>
|
||||
<p className="text-sm text-gray-500">Required</p>
|
||||
</div>
|
||||
{examFile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FaCheckCircle className="w-6 h-6 text-green-500" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExamFile(null);
|
||||
}}
|
||||
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
|
||||
>
|
||||
<FaTimes className="w-4 h-4 text-green-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{examFile && (
|
||||
<div className="mt-2 text-sm text-green-600 font-medium">
|
||||
{examFile.name}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={examInputRef}
|
||||
onChange={handleExamChange}
|
||||
accept=".doc,.docx"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => solutionsInputRef.current?.click()}
|
||||
className={clsx(
|
||||
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
|
||||
solutionsFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FaFileUpload className={clsx(
|
||||
"w-8 h-8",
|
||||
solutionsFile ? "text-green-500" : "text-gray-400"
|
||||
)} />
|
||||
<div className="flex-grow">
|
||||
<h3 className="font-semibold text-gray-700">Solutions Document</h3>
|
||||
<p className="text-sm text-gray-500">Optional</p>
|
||||
</div>
|
||||
{solutionsFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<FaCheckCircle className="w-6 h-6 text-green-500" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSolutionsFile(null);
|
||||
}}
|
||||
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
|
||||
>
|
||||
<FaTimes className="w-4 h-4 text-green-600" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 font-medium px-2 py-1 bg-gray-100 rounded">
|
||||
OPTIONAL
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{solutionsFile && (
|
||||
<div className="mt-2 text-sm text-green-600 font-medium">
|
||||
{solutionsFile.name}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={solutionsInputRef}
|
||||
onChange={handleSolutionsChange}
|
||||
accept=".doc,.docx"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowUploaders(false)}
|
||||
className={
|
||||
clsx("px-6 py-2.5 text-sm font-semibold text-gray-700 bg-white border-2 border-gray-200",
|
||||
"rounded-lg hover:bg-gray-50 hover:border-gray-300",
|
||||
"transition-all duration-300 min-w-[120px]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-gray-200 focus:ring-offset-2",
|
||||
"active:scale-95")}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<FaTimes className="w-4 h-4" />
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!examFile}
|
||||
className={clsx(
|
||||
"flex-grow px-6 py-2.5 text-sm font-semibold rounded-lg",
|
||||
"transition-all duration-300 min-w-[120px]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
"flex items-center justify-center gap-2",
|
||||
examFile
|
||||
? "bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 active:scale-95 focus:ring-blue-500"
|
||||
: "bg-gradient-to-r from-gray-100 to-gray-200 text-gray-400 cursor-not-allowed border-2 border-gray-200"
|
||||
)}
|
||||
>
|
||||
{examFile ? (
|
||||
<>
|
||||
<FaFileUpload className="w-4 h-4" />
|
||||
Import Files
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaLock className="w-4 h-4" />
|
||||
Upload Exam First
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordUploader;
|
||||
40
src/components/ExamEditor/Shared/Passage.tsx
Normal file
40
src/components/ExamEditor/Shared/Passage.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Dropdown from "@/components/Dropdown";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
content: string;
|
||||
open: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const Passage: React.FC<Props> = ({ title, content, open, setIsOpen}) => {
|
||||
const paragraphs = content.split('\n\n');
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
title={title}
|
||||
className={clsx(
|
||||
"bg-white p-6 w-full items-center",
|
||||
open ? "rounded-t-lg border-b border-gray-200" : "rounded-lg shadow-lg"
|
||||
)}
|
||||
titleClassName="text-2xl font-semibold text-gray-800"
|
||||
contentWrapperClassName="p-6 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"
|
||||
open={open}
|
||||
setIsOpen={setIsOpen}
|
||||
>
|
||||
<div>
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={clsx("text-justify", index < paragraphs.length - 1 ? 'mb-4' : 'mb-6')}
|
||||
>
|
||||
{paragraph.trim()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default Passage;
|
||||
84
src/components/ExamEditor/Shared/SectionDropdown.tsx
Normal file
84
src/components/ExamEditor/Shared/SectionDropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
interface DropdownProps {
|
||||
title: ReactNode;
|
||||
open: boolean;
|
||||
toggleOpen: () => void;
|
||||
className: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Would be way too messy to add the center the title in the other Dropdown
|
||||
const SectionDropdown: React.FC<DropdownProps> = ({
|
||||
title,
|
||||
open,
|
||||
className,
|
||||
children,
|
||||
toggleOpen
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
if (contentRef.current) {
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||
const height = entry.borderBoxSize[0].blockSize;
|
||||
setContentHeight(height + 0);
|
||||
} else {
|
||||
// Fallback for browsers that don't support borderBoxSize
|
||||
const height = entry.contentRect.height;
|
||||
setContentHeight(height + 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(contentRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const springProps = useSpring({
|
||||
height: open ? contentHeight : 0,
|
||||
opacity: open ? 1 : 0,
|
||||
config: { tension: 300, friction: 30 }
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleOpen()}
|
||||
className={className}
|
||||
>
|
||||
<div className='flex flex-row w-full items-center'>
|
||||
<p className='flex-grow'>{title}</p>
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<animated.div style={springProps} className="overflow-hidden">
|
||||
<div ref={contentRef}>
|
||||
{children}
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionDropdown;
|
||||
36
src/components/ExamEditor/Shared/SortableSection.tsx
Normal file
36
src/components/ExamEditor/Shared/SortableSection.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { FaArrowsAlt } from "react-icons/fa";
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
const SortableSection: React.FC<{
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex items-center mb-2 bg-white p-4 rounded shadow">
|
||||
<div {...attributes} {...listeners} className="cursor-move mr-2">
|
||||
<div className="cursor-move mr-3 p-2 rounded bg-gray-200 text-gray-500 hover:bg-gray-300">
|
||||
<FaArrowsAlt size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableSection;
|
||||
Reference in New Issue
Block a user