dioxus_ui_system/organisms/
file_upload.rs1use crate::theme::use_theme;
7use dioxus::prelude::*;
8
9#[derive(Clone, PartialEq, Debug)]
11pub enum FileType {
12 Image,
14 Document,
16 Video,
18 Audio,
20 Archive,
22 Other,
24}
25
26impl FileType {
27 pub fn from_file_name(file_name: &str) -> Self {
29 let ext = file_name
30 .split('.')
31 .last()
32 .unwrap_or("")
33 .to_lowercase();
34 match ext.as_str() {
35 "jpg" | "jpeg" | "png" | "gif" | "webp" | "svg" | "bmp" | "ico" | "tiff" | "tif"
36 => FileType::Image,
37 "pdf" | "doc" | "docx" | "txt" | "rtf" | "odt" | "xls" | "xlsx" | "ppt"
38 | "pptx" | "csv" => FileType::Document,
39 "mp4" | "mov" | "avi" | "mkv" | "flv" | "wmv" | "webm" | "m4v"
40 => FileType::Video,
41 "mp3" | "wav" | "flac" | "aac" | "ogg" | "m4a" | "wma"
42 => FileType::Audio,
43 "zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz"
44 => FileType::Archive,
45 _ => FileType::Other,
46 }
47 }
48
49 pub fn icon(&self) -> &'static str {
51 match self {
52 FileType::Image => "🖼️",
53 FileType::Document => "📄",
54 FileType::Video => "🎬",
55 FileType::Audio => "🎵",
56 FileType::Archive => "📦",
57 FileType::Other => "📎",
58 }
59 }
60
61 pub fn color(&self) -> &'static str {
63 match self {
64 FileType::Image => "#3b82f6", FileType::Document => "#64748b", FileType::Video => "#ef4444", FileType::Audio => "#8b5cf6", FileType::Archive => "#f59e0b", FileType::Other => "#6b7280", }
71 }
72}
73
74#[derive(Clone, PartialEq, Debug)]
76pub struct UploadedFile {
77 pub name: String,
78 pub size: u64,
79 pub file_type: String,
80 pub data: Vec<u8>,
81}
82
83#[derive(Props, Clone, PartialEq)]
85pub struct FileUploadProps {
86 #[props(default)]
88 pub accept: Option<String>,
89 #[props(default)]
91 pub max_size_mb: Option<f64>,
92 #[props(default = 1)]
94 pub max_files: usize,
95 #[props(default = false)]
97 pub multiple: bool,
98 #[props(default)]
100 pub single: bool,
101 pub on_upload: EventHandler<Vec<UploadedFile>>,
103 #[props(default)]
105 pub on_change: Option<EventHandler<Vec<UploadedFile>>>,
106 #[props(default)]
108 pub label: Option<String>,
109 #[props(default)]
111 pub helper_text: Option<String>,
112 #[props(default)]
114 pub error: Option<String>,
115 #[props(default = false)]
117 pub loading: bool,
118 #[props(default = false)]
120 pub disabled: bool,
121 #[props(default)]
123 pub class: Option<String>,
124 #[props(default = true)]
126 pub show_file_list: bool,
127}
128
129#[component]
131pub fn FileUpload(props: FileUploadProps) -> Element {
132 let theme = use_theme();
133 let mut is_dragging = use_signal(|| false);
134 let mut files = use_signal(|| Vec::<UploadedFile>::new());
135 let mut validation_error = use_signal(|| None::<String>);
136
137 let allow_multiple = props.multiple && !props.single;
139
140 let class_css = props
141 .class
142 .as_ref()
143 .map(|c| format!(" {}", c))
144 .unwrap_or_default();
145
146 let border_color = if props.error.is_some() || validation_error().is_some() {
147 theme.tokens.read().colors.destructive.to_rgba()
148 } else if is_dragging() {
149 theme.tokens.read().colors.primary.to_rgba()
150 } else {
151 theme.tokens.read().colors.border.to_rgba()
152 };
153
154 let bg_color = if is_dragging() {
155 format!(
156 "{}15",
157 theme
158 .tokens
159 .read()
160 .colors
161 .primary
162 .to_rgba()
163 .trim_start_matches('#')
164 )
165 } else {
166 theme.tokens.read().colors.background.to_rgba()
167 };
168
169 let _validate_file = {
171 let accept = props.accept.clone();
172 let max_size_mb = props.max_size_mb;
173 move |name: &str, size: u64| -> Result<(), String> {
174 if let Some(ref accept) = accept {
176 let is_valid = accept.split(',').any(|pattern| {
177 let pattern = pattern.trim();
178 if pattern == "*/*" {
179 return true;
180 }
181 if pattern.ends_with("/*") {
182 let prefix = pattern.trim_end_matches("/*");
183 let ft = FileType::from_file_name(name);
184 return match prefix {
185 "image" => matches!(ft, FileType::Image),
186 "video" => matches!(ft, FileType::Video),
187 "audio" => matches!(ft, FileType::Audio),
188 "application" => matches!(ft, FileType::Document | FileType::Archive),
189 _ => true,
190 };
191 }
192 name.to_lowercase().ends_with(&pattern.to_lowercase())
193 });
194 if !is_valid {
195 return Err(format!("Invalid file type. Accepted: {}", accept));
196 }
197 }
198
199 if let Some(max_mb) = max_size_mb {
201 let max_bytes = (max_mb * 1024.0 * 1024.0) as u64;
202 if size > max_bytes {
203 return Err(format!(
204 "File too large. Maximum size: {} MB",
205 max_mb
206 ));
207 }
208 }
209
210 Ok(())
211 }
212 };
213
214 let handle_file_select = {
216 let on_upload = props.on_upload.clone();
217 let on_change = props.on_change.clone();
218 let max_files = props.max_files;
219 let allow_multiple = allow_multiple;
220 let validate_file = _validate_file.clone();
221 #[allow(unused_mut, unused_variables)]
222 let mut files_signal = files.clone();
223 #[allow(unused_variables)]
224 move |e: Event<FormData>| {
225 validation_error.set(None);
226
227 #[cfg(all(feature = "web", target_arch = "wasm32"))]
229 {
230 use dioxus::web::WebEventExt;
231 use wasm_bindgen::JsCast;
232 use web_sys::HtmlInputElement;
233
234 if let Some(target) = e.data().as_web_event().target() {
235 if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
236 if let Some(file_list) = input.files() {
237 let mut new_files = Vec::new();
238 let file_count = file_list.length();
239
240 for i in 0..file_count {
241 if let Some(file) = file_list.get(i) {
242 if !allow_multiple && i > 0 {
244 break;
245 }
246 if files_signal().len() + new_files.len() >= max_files {
247 validation_error.set(Some(format!("Maximum {} files allowed", max_files)));
248 break;
249 }
250
251 let name = file.name();
252 let size = file.size() as u64;
253 let file_type = file.type_();
254
255 if let Err(err) = validate_file(&name, size) {
257 validation_error.set(Some(err));
258 continue;
259 }
260
261 new_files.push(UploadedFile {
262 name,
263 size,
264 file_type,
265 data: Vec::new(), });
267 }
268 }
269
270 if !new_files.is_empty() {
272 files_signal.with_mut(|f| {
273 if allow_multiple {
274 f.extend(new_files.clone());
275 } else {
276 *f = new_files.clone();
277 }
278 });
279
280 let all_files = files_signal();
282 on_upload.call(all_files.clone());
283 if let Some(ref on_change_cb) = on_change {
284 on_change_cb.call(all_files.clone());
285 }
286 }
287
288 input.set_value("");
290 }
291 }
292 }
293 }
294
295 #[cfg(not(all(feature = "web", target_arch = "wasm32")))]
297 {
298 let _ = (&on_upload, &on_change, &allow_multiple, &max_files, &validate_file);
299 }
300 }
301 };
302
303 let mut remove_file = {
305 let on_upload = props.on_upload.clone();
306 let on_change = props.on_change.clone();
307 let mut files_signal = files.clone();
308 move |index: usize| {
309 files_signal.with_mut(|f| {
310 if index < f.len() {
311 f.remove(index);
312 }
313 });
314
315 let all_files = files_signal();
317 on_upload.call(all_files.clone());
318 if let Some(ref on_change_cb) = on_change {
319 on_change_cb.call(all_files.clone());
320 }
321 }
322 };
323
324 let mut clear_files = {
326 let on_upload = props.on_upload.clone();
327 let on_change = props.on_change.clone();
328 move || {
329 files.set(Vec::new());
330 on_upload.call(Vec::new());
331 if let Some(ref on_change_cb) = on_change {
332 on_change_cb.call(Vec::new());
333 }
334 }
335 };
336
337 let default_label = if allow_multiple {
339 "Upload files"
340 } else {
341 "Upload file"
342 };
343 let label_text = props.label.clone().unwrap_or_else(|| default_label.to_string());
344
345 let helper_text = props.helper_text.clone().or_else(|| {
346 let mut parts = Vec::new();
347 if let Some(max_mb) = props.max_size_mb {
348 parts.push(format!("Max {} MB", max_mb));
349 }
350 if allow_multiple {
351 parts.push(format!("Max {} files", props.max_files));
352 } else {
353 parts.push("Single file only".to_string());
354 }
355 if props.accept.is_some() {
356 parts.push("Specific types only".to_string());
357 }
358 if parts.is_empty() {
359 None
360 } else {
361 Some(parts.join(" • "))
362 }
363 });
364
365 let file_count = files().len();
367 let at_max_files = file_count >= props.max_files;
368
369 rsx! {
370 div {
371 class: "file-upload{class_css}",
372 style: "display: flex; flex-direction: column; gap: 12px;",
373
374 if !at_max_files {
376 div {
377 class: "file-upload-dropzone",
378 style: "padding: 32px; border: 2px dashed {border_color}; border-radius: 12px; background: {bg_color}; text-align: center; transition: all 0.15s ease;",
379 ondragenter: move |e: Event<dioxus::html::DragData>| {
380 e.prevent_default();
381 if !props.disabled {
382 is_dragging.set(true);
383 }
384 },
385 ondragover: move |e: Event<dioxus::html::DragData>| {
386 e.prevent_default();
387 },
388 ondragleave: move |_| {
389 is_dragging.set(false);
390 },
391 ondrop: {
392 let on_upload = props.on_upload.clone();
393 let on_change = props.on_change.clone();
394 let max_files = props.max_files;
395 let allow_multiple = allow_multiple;
396 let validate_file = _validate_file.clone();
397 #[allow(unused_mut, unused_variables)]
398 let mut files_signal = files.clone();
399 move |e: Event<dioxus::html::DragData>| {
400 e.prevent_default();
401 is_dragging.set(false);
402 validation_error.set(None);
403
404 #[cfg(all(feature = "web", target_arch = "wasm32"))]
405 {
406 use dioxus::web::WebEventExt;
407 use wasm_bindgen::JsCast;
408 use web_sys::DataTransfer;
409
410 if let Some(data_transfer) = e.data().as_web_event().dyn_ref::<DataTransfer>() {
411 if let Some(file_list) = data_transfer.files() {
412 let mut new_files = Vec::new();
413 let file_count = file_list.length();
414
415 for i in 0..file_count {
416 if let Some(file) = file_list.get(i) {
417 if !allow_multiple && i > 0 {
418 break;
419 }
420 if files_signal().len() + new_files.len() >= max_files {
421 validation_error.set(Some(format!("Maximum {} files allowed", max_files)));
422 break;
423 }
424
425 let name = file.name();
426 let size = file.size() as u64;
427 let file_type = file.type_();
428
429 if let Err(err) = validate_file(&name, size) {
430 validation_error.set(Some(err));
431 continue;
432 }
433
434 new_files.push(UploadedFile {
435 name,
436 size,
437 file_type,
438 data: Vec::new(),
439 });
440 }
441 }
442
443 if !new_files.is_empty() {
444 files_signal.with_mut(|f| {
445 if allow_multiple {
446 f.extend(new_files.clone());
447 } else {
448 *f = new_files.clone();
449 }
450 });
451
452 let all_files = files_signal();
453 on_upload.call(all_files.clone());
454 if let Some(ref on_change_cb) = on_change {
455 on_change_cb.call(all_files.clone());
456 }
457 }
458 }
459 }
460 }
461
462 #[cfg(not(all(feature = "web", target_arch = "wasm32")))]
463 {
464 let _ = (&on_upload, &on_change, &allow_multiple, &max_files, &validate_file);
465 }
466 }
467 },
468
469 div {
471 style: "font-size: 40px; margin-bottom: 12px;",
472 "📁"
473 }
474
475 p {
477 style: "margin: 0 0 8px 0; font-size: 16px; font-weight: 500; color: {theme.tokens.read().colors.foreground.to_rgba()};",
478 "{label_text}"
479 }
480
481 if let Some(helper) = helper_text {
483 p {
484 style: "margin: 0 0 16px 0; font-size: 13px; color: {theme.tokens.read().colors.muted.to_rgba()};",
485 "{helper}"
486 }
487 }
488
489 label {
491 class: "file-upload-button",
492 style: "display: inline-block; padding: 10px 20px; font-size: 14px; font-weight: 500; color: white; background: {theme.tokens.read().colors.primary.to_rgba()}; border-radius: 8px; cursor: pointer; transition: opacity 0.15s ease;",
493
494 if allow_multiple {
495 "Browse files"
496 } else {
497 "Browse file"
498 }
499
500 input {
501 r#type: "file",
502 style: "display: none;",
503 accept: props.accept.as_deref().unwrap_or("*/*"),
504 multiple: allow_multiple,
505 disabled: props.disabled || props.loading,
506 onchange: handle_file_select,
507 }
508 }
509 }
510 }
511
512 if let Some(error) = props.error.clone() {
514 p {
515 class: "file-upload-error",
516 style: "margin: 0; font-size: 14px; color: {theme.tokens.read().colors.destructive.to_rgba()};",
517 "{error}"
518 }
519 }
520 if let Some(error) = validation_error() {
521 p {
522 class: "file-upload-validation-error",
523 style: "margin: 0; font-size: 14px; color: {theme.tokens.read().colors.destructive.to_rgba()};",
524 "{error}"
525 }
526 }
527
528 if props.show_file_list && !files().is_empty() {
530 div {
531 class: "file-upload-list-container",
532 style: "margin-top: 8px;",
533
534 div {
536 style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;",
537
538 p {
539 style: "margin: 0; font-size: 14px; font-weight: 500; color: {theme.tokens.read().colors.foreground.to_rgba()};",
540 {
541 let count = files().len();
542 let file_word = if count > 1 { "files" } else { "file" };
543 format!("{} {} selected", count, file_word)
544 }
545 }
546
547 button {
548 r#type: "button",
549 style: "font-size: 12px; color: {theme.tokens.read().colors.muted.to_rgba()}; background: none; border: none; cursor: pointer; padding: 4px 8px;",
550 onclick: move |_| clear_files(),
551 "Clear all"
552 }
553 }
554
555 div {
557 class: "file-upload-list",
558 style: "display: flex; flex-direction: column; gap: 8px;",
559
560 for (i, file) in files().iter().enumerate() {
561 FileUploadItem {
562 key: "{i}",
563 file: file.clone(),
564 on_remove: {
565 move || remove_file(i)
566 },
567 }
568 }
569 }
570 }
571 }
572 }
573 }
574}
575
576#[derive(Props, Clone, PartialEq)]
578pub struct FileUploadItemProps {
579 pub file: UploadedFile,
580 pub on_remove: EventHandler<()>,
581}
582
583#[component]
585fn FileUploadItem(props: FileUploadItemProps) -> Element {
586 let theme = use_theme();
587
588 let file_type = FileType::from_file_name(&props.file.name);
589 let file_icon = file_type.icon();
590 let size_text = format_file_size(props.file.size);
591
592 rsx! {
593 div {
594 class: "file-upload-item",
595 style: "display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid {theme.tokens.read().colors.border.to_rgba()}; border-radius: 8px; background: {theme.tokens.read().colors.background.to_rgba()};",
596
597 div {
599 class: "file-upload-item-icon",
600 style: "flex-shrink: 0; width: 40px; height: 40px; border-radius: 8px; background: {theme.tokens.read().colors.muted.to_rgba()}; display: flex; align-items: center; justify-content: center; font-size: 20px;",
601 "{file_icon}"
602 }
603
604 div {
606 class: "file-upload-item-info",
607 style: "flex: 1; min-width: 0;",
608
609 p {
610 class: "file-upload-item-name",
611 style: "margin: 0; font-size: 14px; font-weight: 500; color: {theme.tokens.read().colors.foreground.to_rgba()}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;",
612 title: "{props.file.name}",
613 "{props.file.name}"
614 }
615
616 p {
617 class: "file-upload-item-size",
618 style: "margin: 4px 0 0 0; font-size: 12px; color: {theme.tokens.read().colors.muted.to_rgba()};",
619 "{size_text} • {props.file.file_type}"
620 }
621 }
622
623 button {
625 r#type: "button",
626 class: "file-upload-item-remove",
627 style: "flex-shrink: 0; background: none; border: none; cursor: pointer; font-size: 18px; color: {theme.tokens.read().colors.muted.to_rgba()}; padding: 4px; transition: color 0.15s ease;",
628 onclick: move |_| props.on_remove.call(()),
629 "✕"
630 }
631 }
632 }
633}
634
635fn format_file_size(size: u64) -> String {
636 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
637 let mut size = size as f64;
638 let mut unit_index = 0;
639
640 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
641 size /= 1024.0;
642 unit_index += 1;
643 }
644
645 format!("{:.1} {}", size, UNITS[unit_index])
646}