Exam generation rework, batch user tables, fastapi endpoint switch

This commit is contained in:
Carlos-Mesquita
2024-11-04 23:29:14 +00:00
parent a2bc997e8f
commit 15c9c4d4bd
148 changed files with 11348 additions and 3901 deletions

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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
}

View 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;

View 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;

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;