Skip to main content

dioxus_ui_system/organisms/
file_upload.rs

1//! File Upload organism component
2//!
3//! Drag-and-drop file upload with progress and preview.
4//! Supports multiple file types, file size limits, and displays file names.
5
6use crate::theme::use_theme;
7use dioxus::prelude::*;
8
9/// Type of file being uploaded
10#[derive(Clone, PartialEq, Debug)]
11pub enum FileType {
12    /// Image file (jpg, png, gif, etc.)
13    Image,
14    /// Document file (pdf, doc, docx, etc.)
15    Document,
16    /// Video file (mp4, mov, etc.)
17    Video,
18    /// Audio file (mp3, wav, etc.)
19    Audio,
20    /// Archive file (zip, rar, etc.)
21    Archive,
22    /// Unknown/other file type
23    Other,
24}
25
26impl FileType {
27    /// Detect file type from extension
28    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    /// Get icon for file type
50    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    /// Get color for file type
62    pub fn color(&self) -> &'static str {
63        match self {
64            FileType::Image => "#3b82f6",    // blue
65            FileType::Document => "#64748b", // slate
66            FileType::Video => "#ef4444",    // red
67            FileType::Audio => "#8b5cf6",    // purple
68            FileType::Archive => "#f59e0b",  // amber
69            FileType::Other => "#6b7280",    // gray
70        }
71    }
72}
73
74/// Uploaded file info
75#[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/// File upload properties
84#[derive(Props, Clone, PartialEq)]
85pub struct FileUploadProps {
86    /// Accepted file types (MIME types or extensions, e.g., "image/*", ".pdf")
87    #[props(default)]
88    pub accept: Option<String>,
89    /// Maximum file size in MB
90    #[props(default)]
91    pub max_size_mb: Option<f64>,
92    /// Maximum number of files
93    #[props(default = 1)]
94    pub max_files: usize,
95    /// Multiple files allowed
96    #[props(default = false)]
97    pub multiple: bool,
98    /// Single file mode (alias for !multiple)
99    #[props(default)]
100    pub single: bool,
101    /// Upload handler
102    pub on_upload: EventHandler<Vec<UploadedFile>>,
103    /// Change handler (called when files are selected)
104    #[props(default)]
105    pub on_change: Option<EventHandler<Vec<UploadedFile>>>,
106    /// Label
107    #[props(default)]
108    pub label: Option<String>,
109    /// Helper text
110    #[props(default)]
111    pub helper_text: Option<String>,
112    /// Error message
113    #[props(default)]
114    pub error: Option<String>,
115    /// Loading state
116    #[props(default = false)]
117    pub loading: bool,
118    /// Disabled state
119    #[props(default = false)]
120    pub disabled: bool,
121    /// Additional CSS classes
122    #[props(default)]
123    pub class: Option<String>,
124    /// Show file list
125    #[props(default = true)]
126    pub show_file_list: bool,
127}
128
129/// File upload component
130#[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    // Determine if multiple files are allowed
138    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    // Validate file closure
170    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            // Check file type
175            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            // Check file size
200            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    // Handle file selection
215    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            // Access the raw HTML input element to get files
228            #[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                                    // Check max files limit
243                                    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                                    // Validate file
256                                    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(), // Data would be read asynchronously
266                                    });
267                                }
268                            }
269                            
270                            // Update files signal
271                            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                                // Call callbacks
281                                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                            // Clear the input value to allow re-selecting the same file
289                            input.set_value("");
290                        }
291                    }
292                }
293            }
294            
295            // Non-web platforms - placeholder
296            #[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    // Remove a file
304    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            // Call callbacks with updated files
316            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    // Clear all files
325    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    // Helper text with constraints info
338    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    // Get current file count for conditional rendering
366    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            // Drop zone (only show if not at max files)
375            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                    // Upload icon
470                    div {
471                        style: "font-size: 40px; margin-bottom: 12px;",
472                        "📁"
473                    }
474
475                    // Label
476                    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                    // Helper text with constraints
482                    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                    // File input
490                    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            // Error messages
513            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            // File list with clear button
529            if props.show_file_list && !files().is_empty() {
530                div {
531                    class: "file-upload-list-container",
532                    style: "margin-top: 8px;",
533
534                    // Header with count and clear button
535                    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                    // File list
556                    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/// File upload item properties
577#[derive(Props, Clone, PartialEq)]
578pub struct FileUploadItemProps {
579    pub file: UploadedFile,
580    pub on_remove: EventHandler<()>,
581}
582
583/// File upload item component
584#[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            // Icon
598            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            // Info
605            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            // Remove button
624            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}