Skip to content

Commit 192cc48

Browse files
committed
feat(components): FileUpload drag & drop con progress bar, validazione MIME/size e multi-file
2 parents dee8e50 + 18cc0cf commit 192cc48

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

web/app/components/FileUpload.tsx

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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

Comments
 (0)