import { useCallback, useRef, useState, type ReactNode } from 'react'; import { Upload, X } from 'lucide-react'; import { cn } from '../utils/cn'; /** * Generic file dropzone with a react-dropzone-compatible API surface. * Implemented on top of the native HTML5 drag-and-drop API so no extra dep is required. * * Differences vs react-dropzone: * - `accept` is a comma-separated MIME string (browser-native form), not the object form * - No fancy filesystem-access fallback; relies on */ export interface FileDropError { code: 'file-too-large' | 'too-many-files' | 'invalid-type'; message: string; file?: File; } interface Props { onFiles: (files: File[]) => void; onError?: (error: FileDropError) => void; /** Comma-separated accept string, e.g. `"image/jpeg,image/png,image/webp"` */ accept?: string; /** Max bytes per file. Default 12 MB. */ maxSize?: number; maxFiles?: number; multiple?: boolean; disabled?: boolean; className?: string; hint?: string; children?: ReactNode; } const DEFAULT_ACCEPT = 'image/jpeg,image/png,image/webp'; const DEFAULT_MAX_SIZE = 12 * 1024 * 1024; // 12 MB const DEFAULT_MAX_FILES = 10; export function FileDrop({ onFiles, onError, accept = DEFAULT_ACCEPT, maxSize = DEFAULT_MAX_SIZE, maxFiles = DEFAULT_MAX_FILES, multiple = true, disabled, className, hint, children, }: Props) { const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); const acceptList = accept .split(',') .map((s) => s.trim()) .filter(Boolean); const matchesAccept = useCallback( (file: File): boolean => { if (acceptList.length === 0) return true; return acceptList.some((rule) => { if (rule.startsWith('.')) return file.name.toLowerCase().endsWith(rule.toLowerCase()); if (rule.endsWith('/*')) { const prefix = rule.slice(0, -1); // keep trailing slash return file.type.startsWith(prefix); } return file.type === rule; }); }, [acceptList], ); const validate = useCallback( (files: File[]): { ok: File[]; error: FileDropError | null } => { if (files.length > maxFiles) { return { ok: [], error: { code: 'too-many-files', message: `En fazla ${maxFiles} dosya yükleyebilirsin.`, }, }; } for (const f of files) { if (!matchesAccept(f)) { return { ok: [], error: { code: 'invalid-type', message: `Desteklenmeyen dosya türü: ${f.name}`, file: f, }, }; } if (f.size > maxSize) { const mb = Math.round((maxSize / (1024 * 1024)) * 10) / 10; return { ok: [], error: { code: 'file-too-large', message: `Dosya çok büyük: ${f.name} (>${mb}MB)`, file: f, }, }; } } return { ok: files, error: null }; }, [maxFiles, matchesAccept, maxSize], ); const handleFiles = useCallback( (list: FileList | null) => { if (!list || list.length === 0) return; const files = Array.from(list); const { ok, error: err } = validate(files); if (err) { setError(err.message); onError?.(err); return; } setError(null); onFiles(ok); }, [onFiles, onError, validate], ); return ( inputRef.current?.click()} onDragOver={(e) => { if (disabled) return; e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={(e) => { if (disabled) return; e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }} className={cn( 'flex w-full flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed p-10 text-center transition-colors', isDragging ? 'border-brand-500 bg-brand-50' : 'border-slate-300 bg-white hover:border-brand-400 hover:bg-slate-50', disabled && 'cursor-not-allowed opacity-60', )} > {children ?? ( <> Dosyaları sürükle bırak veya tıkla {hint ?? `${acceptList.join(', ')} — maks. ${Math.round(maxSize / (1024 * 1024))}MB / dosya`} > )} handleFiles(e.target.files)} /> {error && ( {error} setError(null)} className="ml-auto text-xs underline" > kapat )} ); }