Files
typogenie/src/components/FileUpload.tsx

227 lines
7.2 KiB
TypeScript

import React, { useCallback, useState, useRef, useEffect } from 'react';
import { motion } from 'motion/react';
import { Upload, FileText, AlertCircle } from 'lucide-react';
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
interface FileUploadProps {
onFileLoaded: (content: string, fileName: string) => void;
}
export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropzoneRef = useRef<HTMLDivElement>(null);
const dragCounter = useRef(0);
const handleFile = (file: File) => {
setError(null);
if (!file.name.endsWith('.md') && !file.name.endsWith('.txt') && !file.name.endsWith('.markdown')) {
setError('Please upload a Markdown (.md) or Text (.txt) file.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result;
if (typeof text === 'string') {
// Extract filename without extension
const fileName = file.name.replace(/\.[^/.]+$/, '');
onFileLoaded(text, fileName);
}
};
reader.onerror = () => setError('Error reading file.');
reader.readAsText(file);
};
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter') {
dragCounter.current += 1;
setDragActive(true);
} else if (e.type === 'dragleave') {
dragCounter.current -= 1;
if (dragCounter.current === 0) {
setDragActive(false);
}
} else if (e.type === 'dragover') {
// Keep drag active during dragover
setDragActive(true);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
const openFilePicker = useCallback(() => {
inputRef.current?.click();
}, []);
// Keyboard navigation for the dropzone
useKeyboardNavigation({
onEnter: () => {
if (isFocused) {
openFilePicker();
}
},
onSpace: () => {
if (isFocused) {
openFilePicker();
}
},
}, [isFocused]);
// Clear error after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 5000);
return () => clearTimeout(timer);
}
}, [error]);
return (
<motion.div
className="w-full max-w-xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
role="region"
aria-label="File upload"
>
<motion.div
ref={dropzoneRef}
role="button"
tabIndex={0}
aria-label="Drop zone. Click or press Enter to select a file."
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl cursor-pointer overflow-hidden outline-none transition-all
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50'}
${isFocused ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-zinc-950 border-indigo-400' : ''}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={openFilePicker}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFilePicker();
}
}}
whileHover={{
scale: 1.02,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.05)'
}}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={handleChange}
accept=".md,.txt,.markdown"
aria-label="Select file"
/>
<motion.div
className="flex flex-col items-center justify-center pt-5 pb-6 text-zinc-400 group-hover:text-zinc-200 transition-colors"
animate={dragActive ? { y: [0, -5, 0] } : {}}
transition={{ duration: 0.3 }}
>
<motion.div
className={`p-4 rounded-full mb-4 ${dragActive ? 'bg-indigo-500/20 text-indigo-400' : 'bg-zinc-800 text-zinc-400'}`}
animate={dragActive ? {
scale: [1, 1.2, 1],
rotate: [0, 10, -10, 0]
} : {}}
transition={{ duration: 0.5 }}
whileHover={{ scale: 1.1, rotate: 5 }}
>
<Upload size={32} />
</motion.div>
<motion.p
className="mb-2 text-lg font-medium"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
>
<motion.span
className="font-semibold text-indigo-400"
whileHover={{ color: '#818cf8' }}
>
Click to upload
</motion.span>{' '}
or drag and drop
</motion.p>
<motion.p
className="text-sm text-zinc-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
>
Markdown or Plain Text files
</motion.p>
<motion.p
className="text-xs text-zinc-400 mt-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
Press Enter to browse
</motion.p>
</motion.div>
{/* Animated border gradient on hover */}
<motion.div
className="absolute inset-0 rounded-2xl pointer-events-none"
style={{
background: 'linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1), transparent)',
}}
initial={{ x: '-100%' }}
whileHover={{ x: '100%' }}
transition={{ duration: 0.6, ease: 'easeInOut' }}
/>
</motion.div>
{error && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -10, height: 0 }}
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200"
role="alert"
aria-live="polite"
>
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 0.5 }}
>
<AlertCircle size={20} />
</motion.div>
<span>{error}</span>
</motion.div>
)}
</motion.div>
);
};