0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
import { useLayoutEffect, useRef, useState, useCallback } from 'react';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { FileUp, ShieldCheck, FileText, Image as ImageIcon } from 'lucide-react';
|
|
|
|
interface GlobalDragDropProps {
|
|
onFilesSelected: (files: FileList) => void;
|
|
isAdmin: boolean;
|
|
}
|
|
|
|
let isDragDropEnabled = true;
|
|
let forceHideCallback: (() => void) | null = null;
|
|
|
|
export const setDragDropEnabled = (enabled: boolean) => {
|
|
isDragDropEnabled = enabled;
|
|
if (!enabled && forceHideCallback) {
|
|
forceHideCallback();
|
|
}
|
|
};
|
|
|
|
export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSelected, isAdmin }) => {
|
|
const { t } = useLanguage();
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const dragCounterRef = useRef(0);
|
|
const isDragActiveRef = useRef(false);
|
|
|
|
const hasFiles = useCallback((dt: DataTransfer | null) => {
|
|
if (!dt) return false;
|
|
const hasFileType = dt.types && dt.types.includes('Files');
|
|
if (!hasFileType) return false;
|
|
if (dt.items && dt.items.length > 0) {
|
|
for (let i = 0; i < dt.items.length; i++) {
|
|
if (dt.items[i].kind === 'file') return true;
|
|
}
|
|
return false;
|
|
}
|
|
return hasFileType;
|
|
}, []);
|
|
|
|
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragCounterRef.current++;
|
|
if (dragCounterRef.current === 1) {
|
|
setIsVisible(true);
|
|
isDragActiveRef.current = true;
|
|
}
|
|
}, [hasFiles]);
|
|
|
|
const handleDragOver = useCallback((e: DragEvent) => {
|
|
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer!.dropEffect = 'copy';
|
|
}, [hasFiles]);
|
|
|
|
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
|
if (dragCounterRef.current === 0 && isDragActiveRef.current) {
|
|
setIsVisible(false);
|
|
isDragActiveRef.current = false;
|
|
}
|
|
}, [hasFiles]);
|
|
|
|
const handleDrop = useCallback((e: DragEvent) => {
|
|
if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragCounterRef.current = 0;
|
|
setIsVisible(false);
|
|
isDragActiveRef.current = false;
|
|
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
onFilesSelected(e.dataTransfer.files);
|
|
}
|
|
}, [hasFiles, onFilesSelected]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!isAdmin) return;
|
|
dragCounterRef.current = 0;
|
|
isDragActiveRef.current = false;
|
|
forceHideCallback = () => {
|
|
dragCounterRef.current = 0;
|
|
isDragActiveRef.current = false;
|
|
setIsVisible(false);
|
|
};
|
|
document.addEventListener('dragenter', handleDragEnter);
|
|
document.addEventListener('dragover', handleDragOver);
|
|
document.addEventListener('dragleave', handleDragLeave);
|
|
document.addEventListener('drop', handleDrop);
|
|
return () => {
|
|
document.removeEventListener('dragenter', handleDragEnter);
|
|
document.removeEventListener('dragover', handleDragOver);
|
|
document.removeEventListener('dragleave', handleDragLeave);
|
|
document.removeEventListener('drop', handleDrop);
|
|
forceHideCallback = null;
|
|
dragCounterRef.current = 0;
|
|
isDragActiveRef.current = false;
|
|
setIsVisible(false);
|
|
};
|
|
}, [isAdmin, handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
|
|
|
|
if (!isAdmin || typeof window === 'undefined') return null;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isVisible && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-blue-600/10 backdrop-blur-md items-center justify-center z-[9999] pointer-events-none flex p-8"
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, y: 20 }}
|
|
animate={{ scale: 1, y: 0 }}
|
|
exit={{ scale: 0.9, y: 20 }}
|
|
className="w-full max-w-2xl bg-white rounded-[2.5rem] p-12 text-center shadow-[0_32px_64px_-12px_rgba(0,0,0,0.14)] border border-white pointer-events-auto"
|
|
>
|
|
<div className="flex flex-col items-center justify-center gap-8">
|
|
<div className="w-24 h-24 bg-blue-600 text-white rounded-3xl flex items-center justify-center shadow-xl shadow-blue-200 animate-bounce">
|
|
<FileUp size={48} />
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<h3 className="text-3xl font-black text-slate-900 tracking-tight">
|
|
{t('dragDropUploadTitle')}
|
|
</h3>
|
|
<p className="text-lg text-slate-500 font-medium">
|
|
{t('dropAnywhere')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-center gap-6 py-8 border-y border-slate-100 w-full">
|
|
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
|
|
<ShieldCheck size={20} className="text-emerald-500" />
|
|
<span>{t('secureProcessing')}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
|
|
<FileText size={20} className="text-blue-500" />
|
|
<span>{t('allFormats')}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
|
|
<ImageIcon size={20} className="text-purple-500" />
|
|
<span>{t('visualVision')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-slate-400 font-bold text-xs uppercase tracking-[0.3em]">
|
|
{t('releaseToIngest')}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}; |