287 lines
14 KiB
TypeScript
287 lines
14 KiB
TypeScript
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 { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam';
|
|
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
|
|
|
|
const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: React.Dispatch<React.SetStateAction<number>> }> = ({ module, setNumberOfLevelParts }) => {
|
|
const { currentModule, dispatch } = useExamEditorStore();
|
|
const {sectionLabels} = useExamEditorStore(state => state.modules[currentModule]);
|
|
|
|
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);
|
|
|
|
const newSectionsStates = data.parts.map(
|
|
(part: ReadingPart | ListeningPart | LevelPart, index: number) => defaultSectionSettings(module, index + 1, part)
|
|
);
|
|
|
|
if (module === "level") {
|
|
// default is 1
|
|
const newLabelCount = data.parts.length - 2;
|
|
setNumberOfLevelParts(newLabelCount);
|
|
|
|
const newLabels = Array.from({ length: newLabelCount }, (_, index) => ({
|
|
id: index + 2,
|
|
label: `Part ${index + 2}`
|
|
}));
|
|
|
|
dispatch({type: "UPDATE_MODULE", payload: { updates: { sectionLabels: [...sectionLabels, ...newLabels] }}})
|
|
}
|
|
|
|
dispatch({
|
|
type: "UPDATE_MODULE", payload: {
|
|
updates: {
|
|
sections: newSectionsStates,
|
|
minTimer: data.minTimer,
|
|
importModule: false,
|
|
importing: false,
|
|
},
|
|
module
|
|
}
|
|
});
|
|
} catch (error) {
|
|
toast.error(`Make sure you've imported a valid word document (.docx)!`);
|
|
} finally {
|
|
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: false }, module } })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
examFile,
|
|
solutionsFile,
|
|
dispatch,
|
|
currentModule
|
|
]);
|
|
|
|
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;
|