Files
aurak/web/components/GlobalDragDropOverlay.tsx
T
Developer 0a9588abb7 feat: implement QuestionBank CRUD with pagination and template query
- 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
2026-04-23 17:19:11 +08:00

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>
);
};