|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useState, useRef, useCallback, useId } from 'react' |
| 4 | + |
| 5 | +// ── Types ────────────────────────────────────────────────────────────────── |
| 6 | + |
| 7 | +type UploadedFile = { |
| 8 | + id: string |
| 9 | + file: File |
| 10 | + progress: number // 0–100 |
| 11 | + done: boolean |
| 12 | + error?: string |
| 13 | +} |
| 14 | + |
| 15 | +export type FileUploadProps = { |
| 16 | + accept?: string[] // MIME types o estensioni (es. ['application/pdf']) |
| 17 | + maxSizeMb?: number // default 10 |
| 18 | + multiple?: boolean |
| 19 | + onUpload?: (files: File[]) => Promise<void> |
| 20 | + onRemove?: (file: File) => void |
| 21 | + label?: string |
| 22 | + className?: string |
| 23 | +} |
| 24 | + |
| 25 | +// ── Helpers ──────────────────────────────────────────────────────────────── |
| 26 | + |
| 27 | +function fmtSize(bytes: number) { |
| 28 | + if (bytes < 1024) return `${bytes} B` |
| 29 | + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` |
| 30 | + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` |
| 31 | +} |
| 32 | + |
| 33 | +function fileIcon(type: string) { |
| 34 | + if (type.includes('pdf')) return '📄' |
| 35 | + if (type.includes('image')) return '🖼' |
| 36 | + if (type.includes('word') || type.includes('document')) return '📝' |
| 37 | + return '📎' |
| 38 | +} |
| 39 | + |
| 40 | +function validateFile(file: File, accept: string[], maxSizeMb: number): string | null { |
| 41 | + if (maxSizeMb && file.size > maxSizeMb * 1024 * 1024) |
| 42 | + return `File troppo grande (max ${maxSizeMb} MB)` |
| 43 | + if (accept.length > 0) { |
| 44 | + const ok = accept.some(a => |
| 45 | + a.startsWith('.') ? file.name.toLowerCase().endsWith(a.toLowerCase()) : file.type === a |
| 46 | + ) |
| 47 | + if (!ok) return `Tipo non supportato (accettati: ${accept.join(', ')})` |
| 48 | + } |
| 49 | + return null |
| 50 | +} |
| 51 | + |
| 52 | +// ── FileRow ──────────────────────────────────────────────────────────────── |
| 53 | + |
| 54 | +function FileRow({ uf, onRemove }: { uf: UploadedFile; onRemove: () => void }) { |
| 55 | + return ( |
| 56 | + <div className="flex flex-col gap-1 px-3 py-2 rounded-lg" style={{ background: 'var(--color-deep)', border: '1px solid var(--color-border)' }}> |
| 57 | + <div className="flex items-center gap-2"> |
| 58 | + <span className="text-[13px] flex-shrink-0">{fileIcon(uf.file.type)}</span> |
| 59 | + <div className="flex-1 min-w-0"> |
| 60 | + <p className="text-[10px] font-medium truncate" style={{ color: 'var(--color-bright)' }}>{uf.file.name}</p> |
| 61 | + <p className="text-[9px] font-mono" style={{ color: 'var(--color-dim)' }}>{fmtSize(uf.file.size)}</p> |
| 62 | + </div> |
| 63 | + {uf.done && !uf.error && <span className="text-[10px] flex-shrink-0" style={{ color: 'var(--color-green)' }}>✓</span>} |
| 64 | + {uf.error && <span className="text-[9px] flex-shrink-0 truncate max-w-[100px]" style={{ color: 'var(--color-red)' }}>{uf.error}</span>} |
| 65 | + <button onClick={onRemove} className="flex-shrink-0 text-[11px] hover:opacity-60 transition-opacity" style={{ color: 'var(--color-dim)' }}>✕</button> |
| 66 | + </div> |
| 67 | + {!uf.done && uf.progress > 0 && ( |
| 68 | + <div className="h-0.5 rounded-full overflow-hidden" style={{ background: 'var(--color-border)' }}> |
| 69 | + <div className="h-full rounded-full transition-all duration-200" |
| 70 | + style={{ width: `${uf.progress}%`, background: 'var(--color-blue)' }} /> |
| 71 | + </div> |
| 72 | + )} |
| 73 | + </div> |
| 74 | + ) |
| 75 | +} |
| 76 | + |
| 77 | +// ── FileUpload ───────────────────────────────────────────────────────────── |
| 78 | + |
| 79 | +export function FileUpload({ accept = [], maxSizeMb = 10, multiple = false, onUpload, onRemove, label, className }: FileUploadProps) { |
| 80 | + const [files, setFiles] = useState<UploadedFile[]>([]) |
| 81 | + const [dragOver, setDragOver] = useState(false) |
| 82 | + const inputRef = useRef<HTMLInputElement>(null) |
| 83 | + const uid = useId() |
| 84 | + |
| 85 | + const processFiles = useCallback(async (incoming: FileList | File[]) => { |
| 86 | + const arr = Array.from(incoming) |
| 87 | + const newUfs: UploadedFile[] = arr.map(f => ({ |
| 88 | + id: `${uid}-${f.name}-${Date.now()}`, file: f, |
| 89 | + progress: 0, done: false, |
| 90 | + error: validateFile(f, accept, maxSizeMb) ?? undefined, |
| 91 | + })) |
| 92 | + setFiles(prev => multiple ? [...prev, ...newUfs] : newUfs) |
| 93 | + |
| 94 | + const valid = newUfs.filter(u => !u.error) |
| 95 | + if (!valid.length || !onUpload) { |
| 96 | + setFiles(prev => prev.map(u => newUfs.find(n => n.id === u.id) ? { ...u, done: true } : u)) |
| 97 | + return |
| 98 | + } |
| 99 | + |
| 100 | + // Fake progress 0→85% during upload, 100% on complete |
| 101 | + const tick = setInterval(() => { |
| 102 | + setFiles(prev => prev.map(u => |
| 103 | + valid.find(v => v.id === u.id) && !u.done ? { ...u, progress: Math.min(u.progress + 15, 85) } : u |
| 104 | + )) |
| 105 | + }, 200) |
| 106 | + try { |
| 107 | + await onUpload(valid.map(u => u.file)) |
| 108 | + setFiles(prev => prev.map(u => valid.find(v => v.id === u.id) ? { ...u, progress: 100, done: true } : u)) |
| 109 | + } catch { |
| 110 | + setFiles(prev => prev.map(u => valid.find(v => v.id === u.id) ? { ...u, error: 'Upload fallito' } : u)) |
| 111 | + } finally { |
| 112 | + clearInterval(tick) |
| 113 | + } |
| 114 | + }, [accept, maxSizeMb, multiple, onUpload, uid]) |
| 115 | + |
| 116 | + const handleDrop = (e: React.DragEvent) => { |
| 117 | + e.preventDefault(); setDragOver(false) |
| 118 | + if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files) |
| 119 | + } |
| 120 | + |
| 121 | + const handleRemove = (id: string) => { |
| 122 | + const uf = files.find(f => f.id === id) |
| 123 | + if (uf) onRemove?.(uf.file) |
| 124 | + setFiles(prev => prev.filter(f => f.id !== id)) |
| 125 | + } |
| 126 | + |
| 127 | + const color = dragOver ? 'var(--color-blue)' : 'var(--color-border)' |
| 128 | + |
| 129 | + return ( |
| 130 | + <div className={`flex flex-col gap-2 ${className ?? ''}`}> |
| 131 | + {label && <p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: 'var(--color-dim)' }}>{label}</p>} |
| 132 | + |
| 133 | + {/* Drop zone */} |
| 134 | + <div onClick={() => inputRef.current?.click()} |
| 135 | + onDragOver={e => { e.preventDefault(); setDragOver(true) }} |
| 136 | + onDragLeave={() => setDragOver(false)} |
| 137 | + onDrop={handleDrop} |
| 138 | + className="flex flex-col items-center justify-center gap-1 py-6 rounded-xl cursor-pointer transition-colors" |
| 139 | + style={{ border: `1.5px dashed ${color}`, background: dragOver ? 'var(--color-blue)08' : 'transparent' }}> |
| 140 | + <span className="text-[20px]">📂</span> |
| 141 | + <p className="text-[10px]" style={{ color: 'var(--color-muted)' }}> |
| 142 | + Trascina qui o <span style={{ color: 'var(--color-blue)' }}>seleziona file</span> |
| 143 | + </p> |
| 144 | + <p className="text-[9px]" style={{ color: 'var(--color-dim)' }}> |
| 145 | + {accept.length ? accept.join(', ') : 'Tutti i tipi'} · max {maxSizeMb} MB |
| 146 | + </p> |
| 147 | + </div> |
| 148 | + |
| 149 | + <input ref={inputRef} type="file" className="hidden" |
| 150 | + accept={accept.join(',')} multiple={multiple} |
| 151 | + onChange={e => e.target.files?.length && processFiles(e.target.files)} /> |
| 152 | + |
| 153 | + {/* File list */} |
| 154 | + {files.length > 0 && ( |
| 155 | + <div className="flex flex-col gap-1.5"> |
| 156 | + {files.map(uf => <FileRow key={uf.id} uf={uf} onRemove={() => handleRemove(uf.id)} />)} |
| 157 | + </div> |
| 158 | + )} |
| 159 | + </div> |
| 160 | + ) |
| 161 | +} |
0 commit comments