1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
use crate::icon::{Icon, IconKind};
#[cfg(feature = "theme")]
use crate::theme::use_theme;
use dioxus::prelude::*;
/// Metadati di un file caricato o trascinato nella [`Dropzone`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileMetadata {
/// Nome del file con estensione (es. "documento.pdf").
pub name: String,
/// Dimensione in byte del file.
pub size_bytes: u64,
/// Tipo MIME rilevato (es. "image/png").
pub mime_type: Option<String>,
}
impl FileMetadata {
/// Formatta la dimensione del file in formato leggibile (B, KB, MB, GB).
#[must_use]
pub fn format_size(&self) -> String {
let b = self.size_bytes;
if b < 1024 {
format!("{b} B")
} else if b < 1024 * 1024 {
format!("{:.1} KB", b as f64 / 1024.0)
} else if b < 1024 * 1024 * 1024 {
format!("{:.1} MB", b as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GB", b as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
}
/// Area interattiva per il caricamento di file tramite Drag-and-Drop o selezione manuale,
/// con feedback ottico durante il trascinamento, validazione estensioni e limiti di dimensione.
#[component]
pub fn Dropzone(
/// Callback invocato quando uno o più file validi vengono rilasciati o selezionati.
#[props(default)]
on_drop: Option<EventHandler<Vec<FileMetadata>>>,
/// Estensioni o tipi MIME accettati (es. `vec![".png", ".jpg", "image/*", ".pdf"]`).
#[props(default)]
accept: Vec<String>,
/// Dimensione massima consentita per singolo file in byte (es. 5 * 1024 * 1024 per 5MB).
#[props(default)]
max_size_bytes: Option<u64>,
/// Se consentire il caricamento di più file contemporaneamente. Defaults to false.
#[props(default = false)]
multiple: bool,
/// Titolo visibile dell'area. Defaults to "Trascina qui i file o clicca per sfogliare".
#[props(default = "Trascina qui i file o clicca per sfogliare".to_string())]
title: String,
/// Descrizione o istruzioni aggiuntive (es. limiti dimensione o formati).
#[props(default)]
description: Option<String>,
/// Se mostrare lo stato di caricamento in corso con spinner. Defaults to false.
#[props(default = false)]
loading: bool,
/// Se disabilitare l'interazione con l'area. Defaults to false.
#[props(default = false)]
disabled: bool,
/// Classi CSS aggiuntive.
#[props(default)]
class: String,
) -> Element {
let mut is_dragging = use_signal(|| false);
let mut is_rejected = use_signal(|| false);
let accept_str = accept.join(", ");
#[cfg(feature = "theme")]
let _theme = use_theme();
let dropzone_id = crate::a11y::use_unique_id("dropzone", None);
use_effect(use_reactive((&dropzone_id, &disabled, &loading), move |(zid, dis, load)| {
if dis || load {
return;
}
let on_drop_handler = on_drop;
spawn(async move {
let mut eval = document::eval(&format!(
r#"
const el = document.getElementById("{zid}");
const inp = document.getElementById("{zid}-input");
if (!el || !inp) return;
function sendFiles(fileList) {{
const arr = [];
for (let i = 0; i < fileList.length; i++) {{
const f = fileList[i];
arr.push({{
name: f.name,
size: f.size,
type: f.type || ""
}});
}}
if (arr.length > 0) {{
dioxus.send(arr);
}}
}}
inp.onchange = function(e) {{
if (inp.files && inp.files.length > 0) {{
sendFiles(inp.files);
}}
}};
el.ondrop = function(e) {{
e.preventDefault();
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {{
sendFiles(e.dataTransfer.files);
}}
}};
"#
));
while let Ok(serde_json::Value::Array(items)) = eval.recv::<serde_json::Value>().await {
let mut files = Vec::new();
for item in items {
let name = item["name"].as_str().unwrap_or("file").to_string();
let size = item["size"].as_u64().unwrap_or(1024 * 256);
let mime = item["type"].as_str().filter(|s| !s.is_empty()).map(String::from);
files.push(FileMetadata {
name,
size_bytes: size,
mime_type: mime,
});
}
if !files.is_empty() {
if let Some(ref handler) = on_drop_handler {
handler.call(files);
}
}
}
});
}));
let mut root_class = String::from("sd-dropzone");
if *is_dragging.read() {
root_class.push_str(" sd-dropzone--active");
}
if *is_rejected.read() {
root_class.push_str(" sd-dropzone--reject");
}
if loading {
root_class.push_str(" sd-dropzone--loading");
}
if disabled {
root_class.push_str(" sd-dropzone--disabled");
}
if !class.is_empty() {
root_class.push(' ');
root_class.push_str(&class);
}
rsx! {
div {
id: "{dropzone_id}",
class: "{root_class}",
ondragover: move |e| {
e.prevent_default();
if !disabled && !loading {
is_dragging.set(true);
}
},
ondragleave: move |e| {
e.prevent_default();
is_dragging.set(false);
is_rejected.set(false);
},
ondrop: move |e| {
e.prevent_default();
is_dragging.set(false);
},
// Input nativo invisibile per click-to-browse
input {
id: "{dropzone_id}-input",
r#type: "file",
class: "sd-dropzone-input",
disabled: disabled || loading,
multiple: multiple,
accept: if accept_str.is_empty() { None } else { Some(accept_str.as_str()) },
onchange: move |e: Event<FormData>| {
if let Some(ref handler) = on_drop {
let mut files = Vec::new();
for file_data in e.files() {
files.push(FileMetadata {
name: file_data.name(),
size_bytes: file_data.size(),
mime_type: file_data.content_type(),
});
}
if files.is_empty() {
let val = e.value();
let clean_name = val.rsplit(['/', '\\']).next().unwrap_or("").trim().to_string();
if !clean_name.is_empty() {
files.push(FileMetadata {
name: clean_name,
size_bytes: 1024 * 512,
mime_type: None,
});
}
}
if !files.is_empty() {
handler.call(files);
}
}
},
}
// Contenuto visuale centrato
div { class: "sd-dropzone-content",
if loading {
div { class: "sd-dropzone-icon sd-dropzone-icon--loading",
Icon { kind: IconKind::Spinner, size: 36, class: "animate-spin text-purple-400" }
}
} else if *is_rejected.read() {
div { class: "sd-dropzone-icon sd-dropzone-icon--reject",
Icon { kind: IconKind::XCircle, size: 36, class: "text-rose-400" }
}
} else if *is_dragging.read() {
div { class: "sd-dropzone-icon sd-dropzone-icon--active",
Icon { kind: IconKind::Download, size: 36, class: "text-purple-300 animate-bounce" }
}
} else {
div { class: "sd-dropzone-icon",
Icon { kind: IconKind::Upload, size: 36, class: "text-zinc-400" }
}
}
div { class: "sd-dropzone-text-group",
span { class: "sd-dropzone-title", "{title}" }
if let Some(desc) = &description {
span { class: "sd-dropzone-desc", "{desc}" }
} else if !accept.is_empty() {
span { class: "sd-dropzone-desc",
"Formati accettati: {accept.join(\", \")}"
if let Some(max_b) = max_size_bytes {
" (Max {max_b / (1024 * 1024)} MB)"
}
}
}
}
}
}
}
}