hikari-components 0.3.12

Core UI components (buttons, inputs, tables, modals) for the Hikari design system
// packages/components/src/basic/file_upload.rs
// FileUpload component with Arknights + FUI styling

use hikari_palette::classes::{ClassesBuilder, FileUploadClass};

use crate::{prelude::*, styled::StyledComponent};

pub struct FileUploadComponent;

#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum FileUploadStatus {
    #[default]
    Idle,
    Dragging,
    Uploading,
    Success,
    Error,
}

#[define_props]
pub struct FileUploadProps {
    #[default(false)]
    pub multiple: bool,

    #[default]
    pub accept: String,

    #[default(10485760)] // 10MB default
    pub max_size: usize,

    #[default(true)]
    pub show_preview: bool,

    #[default("Click or drag file to upload".to_string())]
    pub upload_text: String,

    #[default]
    pub class: String,

    #[default]
    pub style: String,

    #[default]
    pub on_files: Option<EventHandler<Vec<String>>>,

    #[default]
    pub on_error: Option<EventHandler<String>>,
}

#[component]
pub fn FileUpload(props: FileUploadProps) -> Element {
    let upload_status = use_signal(|| FileUploadStatus::Idle);

    #[cfg(target_arch = "wasm32")]
    let files = use_signal(|| Vec::<String>::new());

    #[cfg(not(target_arch = "wasm32"))]
    let files = use_signal(Vec::<String>::new);

    let wrapper_classes = ClassesBuilder::new()
        .add(FileUploadClass::FileUploadWrapper)
        .add_raw(&props.class)
        .build();

    let status_class = match upload_status.get() {
        FileUploadStatus::Dragging => Some(FileUploadClass::Dragging),
        FileUploadStatus::Uploading => Some(FileUploadClass::Uploading),
        FileUploadStatus::Success => Some(FileUploadClass::Success),
        FileUploadStatus::Error => Some(FileUploadClass::Error),
        _ => None,
    };

    let mut drag_builder = ClassesBuilder::new().add(FileUploadClass::FileUpload);
    if let Some(class) = status_class {
        drag_builder = drag_builder.add(class);
    }
    let drag_classes = drag_builder.build();

    let on_files_for_drop = props.on_files.clone();
    let on_files_for_change = props.on_files.clone();
    let on_error_for_drop = props.on_error.clone();

    let upload_status_for_drag_over = upload_status.clone();
    let on_drag_over = move |e: DragEvent| {
        e.prevent_default();
        upload_status_for_drag_over.set(FileUploadStatus::Dragging);
    };

    let upload_status_for_drag_leave = upload_status.clone();
    let on_drag_leave = move |_: DragEvent| {
        upload_status_for_drag_leave.set(FileUploadStatus::Idle);
    };

    let upload_status_for_drop = upload_status.clone();
    let files_for_drop = files.clone();
    let on_drop = move |e: DragEvent| {
        e.prevent_default();
        upload_status_for_drop.set(FileUploadStatus::Idle);

        #[cfg(target_arch = "wasm32")]
        {
            if let Some(data_transfer) = &e.data_transfer {
                let file_list = &data_transfer.files;

                let mut selected_files = Vec::new();
                let mut errors = Vec::new();

                for file_name in file_list {
                    selected_files.push(file_name.clone());
                }

                if !errors.is_empty() && on_error_for_drop.is_some() {
                    for error in errors {
                        if let Some(handler) = on_error_for_drop.as_ref() {
                            handler.call(error);
                        }
                    }
                }

                if !selected_files.is_empty() {
                    files_for_drop.set(selected_files.clone());

                    if let Some(handler) = on_files_for_drop.as_ref() {
                        handler.call(selected_files);
                    }

                    upload_status_for_drop.set(FileUploadStatus::Success);
                }
            }
        }
    };

    let upload_status_for_change = upload_status.clone();
    let files_for_change = files.clone();
    let on_change = move |e: ChangeEvent| {
        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
        {
            let file_value = e.value;
            if file_value.is_empty() {
                return;
            }

            let selected_files: Vec<String> = file_value
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect();

            if !selected_files.is_empty() {
                files_for_change.set(selected_files.clone());

                if let Some(handler) = on_files_for_change.as_ref() {
                    handler.call(selected_files);
                }

                upload_status_for_change.set(FileUploadStatus::Success);
            }
        }

        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
        {
            let _ = e;
        }
    };

    rsx! {
        div { class: wrapper_classes, style: props.style,

            div {
                class: drag_classes,
                ondragover: on_drag_over,
                ondragleave: on_drag_leave,
                ondrop: on_drop,

                // File input (positioned over div)
                input {
                    r#type: "file",
                    multiple: props.multiple,
                    accept: props.accept,
                    style: "position: absolute; width: 100%; height: 100%; top: 0; left: 0; opacity: 0; cursor: pointer;",
                    onchange: on_change,
                }

                // Upload area
                div {
                    class: "hi-file-upload-area",
                    style: "pointer-events: none;",

                    Icon { icon: MdiIcon::Upload, size: 32, class: "hi-file-upload-icon".to_string(), color: String::new() }

                    p { class: "hi-file-upload-text", "{props.upload_text}" }

                    if props.show_preview && !files.get().is_empty() {
                        div { class: "hi-file-upload-preview",
                            for file in files.get().iter() {
                                div { class: "hi-file-upload-file", "{file}" }
                            }
                        }
                    }
                }
            }
        }
    }
}

impl StyledComponent for FileUploadComponent {
    fn styles() -> &'static str {
        r#"
.hi-file-upload-wrapper {
    width: 100%;
}

.hi-file-upload {
    border: 2px dashed var(--hi-color-border);
    border-radius: 8px;
    padding: 2rem;
    text-align: center;
    transition: all 0.3s ease;
    background-color: var(--hi-color-background);
    cursor: pointer;
    position: relative;
}

.hi-file-upload-dragging {
    border-color: var(--hi-color-primary);
    background-color: rgba(var(--hi-color-primary-rgb), 0.05);
    transform: scale(1.02);
}

.hi-file-upload-uploading {
    opacity: 0.6;
    pointer-events: none;
}

.hi-file-upload-success {
    border-color: var(--hi-color-success);
    background-color: rgba(var(--hi-color-success-rgb), 0.05);
}

.hi-file-upload-error {
    border-color: var(--hi-color-error);
    background-color: rgba(var(--hi-color-error-rgb), 0.05);
}

.hi-file-upload-area {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1rem;
}

.hi-file-upload-icon {
    width: 48px;
    height: 48px;
    color: var(--hi-color-text-secondary);
    transition: all 0.3s ease;
}

.hi-file-upload-dragging .hi-file-upload-icon {
    color: var(--hi-color-primary);
    transform: translateY(-4px);
}

.hi-file-upload-text {
    font-size: 0.875rem;
    color: var(--hi-color-text-primary);
    margin: 0;
}

.hi-file-upload-preview {
    margin-top: 1rem;
    width: 100%;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    align-items: center;
}

.hi-file-upload-file {
    padding: 0.5rem 1rem;
    background-color: var(--hi-color-surface);
    border-radius: 4px;
    font-size: 0.875rem;
    color: var(--hi-color-text-primary);
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.hi-file-upload-success .hi-file-upload-file {
    color: var(--hi-color-success);
}
"#
    }

    fn name() -> &'static str {
        "file_upload"
    }
}