use std::collections::HashSet; use dioxus::prelude::*; use super::utils::{format_size, type_badge_class}; use crate::client::{ CollectionResponse, DirectoryPreviewFile, ImportEvent, ScanStatusResponse, TagResponse, }; /// Import event for batch: (paths, tag_ids, new_tags, collection_id) pub type BatchImportEvent = (Vec, Vec, Vec, Option); #[component] pub fn Import( tags: Vec, collections: Vec, on_import_file: EventHandler, on_import_directory: EventHandler, on_import_batch: EventHandler, on_scan: EventHandler<()>, on_preview_directory: EventHandler<(String, bool)>, preview_files: Vec, preview_total_size: u64, scan_progress: Option, #[props(default = false)] is_importing: bool, // Extended import state #[props(default)] current_file: Option, #[props(default)] import_queue: Vec, #[props(default = (0, 0))] import_progress: (usize, usize), ) -> Element { let mut import_mode = use_signal(|| 0usize); let mut file_path = use_signal(String::new); let mut dir_path = use_signal(String::new); let selected_tags = use_signal(Vec::::new); let new_tags_input = use_signal(String::new); let selected_collection = use_signal(|| Option::::None); // Recursive toggle for directory preview let mut recursive = use_signal(|| true); // Filter state for directory preview let mut filter_types = use_signal(|| vec![true, true, true, true, true, true]); // audio, video, image, document, text, other let mut filter_min_size = use_signal(|| 0u64); let mut filter_max_size = use_signal(|| 0u64); // 0 means no limit // File selection state let mut selected_file_paths = use_signal(HashSet::::new); let current_mode = *import_mode.read(); rsx! { // Import status panel (shown when import is in progress) if is_importing { { let (completed, total) = import_progress; let has_progress = total > 0; let pct = (completed * 100).checked_div(total).unwrap_or(0); let queue_count = import_queue.len(); rsx! { div { class: "import-status-panel", div { class: "import-status-header", div { class: "status-dot checking" } span { if has_progress { "Importing {completed}/{total}..." } else { "Import in progress..." } } } // Show current file being imported if let Some(ref file_name) = current_file { div { class: "import-current-file", span { class: "import-file-label", "Current: " } span { class: "import-file-name", "{file_name}" } } } // Show queue indicator if queue_count > 0 { div { class: "import-queue-indicator", span { class: "import-queue-badge", "{queue_count}" } span { class: "import-queue-text", " item(s) queued" } } } div { class: "progress-bar", if has_progress { div { class: "progress-fill", style: "width: {pct}%;" } } else { div { class: "progress-fill indeterminate" } } } } } } } // Tab bar div { class: "import-tabs", button { class: if current_mode == 0 { "import-tab active" } else { "import-tab" }, onclick: move |_| import_mode.set(0), "Single File" } button { class: if current_mode == 1 { "import-tab active" } else { "import-tab" }, onclick: move |_| import_mode.set(1), "Directory" } button { class: if current_mode == 2 { "import-tab active" } else { "import-tab" }, onclick: move |_| import_mode.set(2), "Scan Roots" } } // Mode 0: Single File if current_mode == 0 { div { class: "card mb-16", div { class: "card-header", h3 { class: "card-title", "Import Single File" } } div { class: "form-group", label { class: "form-label", "File Path" } div { class: "form-row", input { r#type: "text", placeholder: "/path/to/file...", value: "{file_path}", oninput: move |e| file_path.set(e.value()), onkeypress: { let mut file_path = file_path; let mut selected_tags = selected_tags; let mut new_tags_input = new_tags_input; let mut selected_collection = selected_collection; move |e: KeyboardEvent| { if e.key() == Key::Enter { let path = file_path.read().clone(); if !path.is_empty() { let tag_ids = selected_tags.read().clone(); let new_tags = parse_new_tags(&new_tags_input.read()); let col_id = selected_collection.read().clone(); on_import_file.call((path, tag_ids, new_tags, col_id)); file_path.set(String::new()); selected_tags.set(Vec::new()); new_tags_input.set(String::new()); selected_collection.set(None); } } } }, } button { class: "btn btn-secondary", onclick: move |_| { let mut file_path = file_path; spawn(async move { if let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await { file_path.set(handle.path().to_string_lossy().to_string()); } }); }, "Browse..." } button { class: "btn btn-primary", disabled: is_importing, onclick: { let mut file_path = file_path; let mut selected_tags = selected_tags; let mut new_tags_input = new_tags_input; let mut selected_collection = selected_collection; move |_| { let path = file_path.read().clone(); if !path.is_empty() { let tag_ids = selected_tags.read().clone(); let new_tags = parse_new_tags(&new_tags_input.read()); let col_id = selected_collection.read().clone(); on_import_file.call((path, tag_ids, new_tags, col_id)); file_path.set(String::new()); selected_tags.set(Vec::new()); new_tags_input.set(String::new()); selected_collection.set(None); } } }, if is_importing { "Importing..." } else { "Import" } } } } } ImportOptions { tags: tags.clone(), collections: collections.clone(), selected_tags, new_tags_input, selected_collection, } } // Mode 1: Directory if current_mode == 1 { div { class: "card mb-16", div { class: "card-header", h3 { class: "card-title", "Import Directory" } } div { class: "form-group", label { class: "form-label", "Directory Path" } div { class: "form-row", input { r#type: "text", placeholder: "/path/to/directory...", value: "{dir_path}", oninput: move |e| dir_path.set(e.value()), onkeypress: { let dir_path = dir_path; let recursive = recursive; move |e: KeyboardEvent| { if e.key() == Key::Enter { let path = dir_path.read().clone(); if !path.is_empty() { on_preview_directory.call((path, *recursive.read())); } } } }, } button { class: "btn btn-secondary", onclick: move |_| { let mut dir_path = dir_path; let recursive = recursive; spawn(async move { if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await { let path = handle.path().to_string_lossy().to_string(); dir_path.set(path.clone()); on_preview_directory.call((path, *recursive.read())); } }); }, "Browse..." } button { class: "btn btn-secondary", onclick: { let dir_path = dir_path; let recursive = recursive; move |_| { let path = dir_path.read().clone(); if !path.is_empty() { on_preview_directory.call((path, *recursive.read())); } } }, "Preview" } } } // Recursive toggle div { class: "form-group", label { class: "checkbox-label", input { r#type: "checkbox", checked: *recursive.read(), onchange: move |_| recursive.toggle(), } span { "Recursive (include subdirectories)" } } } } // Preview results if !preview_files.is_empty() { { // Read filter signals once before the loop to avoid per-item reads let types_snapshot = filter_types.read().clone(); let min = *filter_min_size.read(); let max = *filter_max_size.read(); let filtered: Vec<&DirectoryPreviewFile> = preview_files // Read selection once for display .iter() // Filter bar // Selection toolbar // Deselect all filtered // Select all filtered .filter(|f| { let type_idx = match type_badge_class(&f.media_type) { "type-audio" => 0, "type-video" => 1, "type-image" => 2, "type-document" => 3, "type-text" => 4, _ => 5, }; if !types_snapshot[type_idx] { return false; } if min > 0 && f.file_size < min { return false; } if max > 0 && f.file_size > max { return false; } true }) .collect(); let filtered_count = filtered.len(); let total_count = preview_files.len(); let selection = selected_file_paths.read().clone(); let selected_count = selection.len(); let all_filtered_selected = !filtered.is_empty() && filtered.iter().all(|f| selection.contains(&f.path)); let filtered_paths: Vec = filtered .iter() .map(|f| f.path.clone()) .collect(); rsx! { div { class: "card mb-16", div { class: "card-header", h3 { class: "card-title", "Preview" } p { class: "text-muted text-sm", "{filtered_count} of {total_count} files shown, {format_size(preview_total_size)} total" } } div { class: "filter-bar", div { class: "filter-row", span { class: "filter-label", "Types" } label { class: if types_snapshot[0] { "filter-chip active" } else { "filter-chip" }, input { r#type: "checkbox", checked: types_snapshot[0], onchange: move |_| { let mut types = filter_types.read().clone(); types[0] = !types[0]; filter_types.set(types); }, } "Audio" } label { class: if types_snapshot[1] { "filter-chip active" } else { "filter-chip" }, input { r#type: "checkbox", checked: types_snapshot[1], onchange: move |_| { let mut types = filter_types.read().clone(); types[1] = !types[1]; filter_types.set(types); }, } "Video" } label { class: if types_snapshot[2] { "filter-chip active" } else { "filter-chip" }, input { r#type: "checkbox", checked: types_snapshot[2], onchange: move |_| { let mut types = filter_types.read().clone(); types[2] = !types[2]; filter_types.set(types); }, } "Image" } label { class: if types_snapshot[3] { "filter-chip active" } else { "filter-chip" }, input { r#type: "checkbox", checked: types_snapshot[3], onchange: move |_| { let mut types = filter_types.read().clone(); types[3] = !types[3]; filter_types.set(types); }, } "Document" } label { class: if types_snapshot[4] { "filter-chip active" } else { "filter-chip" }, input { r#type: "checkbox", checked: types_snapshot[4], onchange: move |_| { let mut types = filter_types.read().clone(); types[4] = !types[4]; filter_types.set(types); }, } "Text" } label { class: if types_snapshot[5] { "filter-chip active" } else { "filter-chip" }, input { r#type: "checkbox", checked: types_snapshot[5], onchange: move |_| { let mut types = filter_types.read().clone(); types[5] = !types[5]; filter_types.set(types); }, } "Other" } } div { class: "size-filters", div { class: "size-filter-group", label { "Min size" } input { r#type: "number", placeholder: "MB", value: if min > 0 { format!("{}", min / (1024 * 1024)) } else { String::new() }, oninput: move |e| { if let Ok(mb) = e.value().parse::() { filter_min_size.set(mb * 1024 * 1024); } else { filter_min_size.set(0); } }, } span { class: "text-muted text-sm", "MB" } } div { class: "size-filter-group", label { "Max size" } input { r#type: "number", placeholder: "MB", value: if max > 0 { format!("{}", max / (1024 * 1024)) } else { String::new() }, oninput: move |e| { if let Ok(mb) = e.value().parse::() { filter_max_size.set(mb * 1024 * 1024); } else { filter_max_size.set(0); } }, } span { class: "text-muted text-sm", "MB" } } } } div { class: "flex-row mb-8", style: "gap: 8px; align-items: center; padding: 0 8px;", button { class: "btn btn-sm btn-secondary", onclick: { let filtered_paths = filtered_paths.clone(); move |_| { let mut sel = selected_file_paths.read().clone(); for p in &filtered_paths { sel.insert(p.clone()); } selected_file_paths.set(sel); } }, "Select All ({filtered_count})" } button { class: "btn btn-sm btn-ghost", onclick: move |_| { selected_file_paths.set(HashSet::new()); }, "Deselect All" } if selected_count > 0 { span { class: "text-muted text-sm", "{selected_count} files selected" } } } div { style: "max-height: 400px; overflow-y: auto;", table { class: "data-table", thead { tr { th { style: "width: 32px;", input { r#type: "checkbox", checked: all_filtered_selected, onclick: { let filtered_paths = filtered_paths.clone(); move |_| { if all_filtered_selected { let filtered_set: HashSet = filtered_paths .iter() .cloned() .collect(); let sel = selected_file_paths.read().clone(); let new_sel: HashSet = sel .difference(&filtered_set) .cloned() .collect(); selected_file_paths.set(new_sel); } else { let mut sel = selected_file_paths.read().clone(); for p in &filtered_paths { sel.insert(p.clone()); } selected_file_paths.set(sel); } } }, } } th { "File Name" } th { "Type" } th { "Size" } } } tbody { for file in filtered.iter() { { let size = format_size(file.file_size); let badge_class = type_badge_class(&file.media_type); let is_selected = selection.contains(&file.path); let file_path_clone = file.path.clone(); rsx! { tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" }, td { input { r#type: "checkbox", checked: is_selected, onclick: { let path = file_path_clone.clone(); move |_| { let mut sel = selected_file_paths.read().clone(); if sel.contains(&path) { sel.remove(&path); } else { sel.insert(path.clone()); } selected_file_paths.set(sel); } }, } } td { "{file.file_name}" } td { span { class: "type-badge {badge_class}", "{file.media_type}" } } td { "{size}" } } } } } } } } } } } } ImportOptions { tags: tags.clone(), collections: collections.clone(), selected_tags, new_tags_input, selected_collection, } div { class: "flex-row mb-16", style: "gap: 8px;", // Import selected files only (batch import) { let sel_count = selected_file_paths.read().len(); let has_selected = sel_count > 0; rsx! { button { class: "btn btn-primary", disabled: !has_selected || is_importing, onclick: { let mut selected_file_paths = selected_file_paths; let mut selected_tags = selected_tags; let mut new_tags_input = new_tags_input; let mut selected_collection = selected_collection; move |_| { let paths: Vec = selected_file_paths .read() .iter() .cloned() .collect(); if !paths.is_empty() { let tag_ids = selected_tags.read().clone(); let new_tags = parse_new_tags(&new_tags_input.read()); let col_id = selected_collection.read().clone(); on_import_batch.call((paths, tag_ids, new_tags, col_id)); selected_file_paths.set(HashSet::new()); selected_tags.set(Vec::new()); new_tags_input.set(String::new()); selected_collection.set(None); } } }, if is_importing { "Importing..." } else if has_selected { "Import Selected ({sel_count})" } else { "Import Selected" } } } } // Import entire directory { let has_dir = !dir_path.read().is_empty(); let has_preview = !preview_files.is_empty(); let file_count = preview_files.len(); rsx! { button { class: if has_dir { "btn btn-secondary" } else { "btn btn-secondary btn-disabled-hint" }, disabled: is_importing || !has_dir, title: if !has_dir { "Select a directory first" } else { "" }, onclick: { let mut dir_path = dir_path; let mut selected_tags = selected_tags; let mut new_tags_input = new_tags_input; let mut selected_collection = selected_collection; let mut selected_file_paths = selected_file_paths; move |_| { let path = dir_path.read().clone(); if !path.is_empty() { let tag_ids = selected_tags.read().clone(); let new_tags = parse_new_tags(&new_tags_input.read()); let col_id = selected_collection.read().clone(); on_import_directory.call((path, tag_ids, new_tags, col_id)); dir_path.set(String::new()); selected_tags.set(Vec::new()); new_tags_input.set(String::new()); selected_collection.set(None); selected_file_paths.set(HashSet::new()); } } }, if is_importing { "Importing..." } else if has_preview { "Import All ({file_count} files)" } else if has_dir { "Import Entire Directory" } else { "Select Directory First" } } } } } } // Mode 2: Scan Roots if current_mode == 2 { div { class: "card mb-16", div { class: "card-header", h3 { class: "card-title", "Scan Root Directories" } } div { class: "empty-state", p { class: "empty-subtitle", "Scan all configured root directories for media files. " "This will discover and import any new files found in your root paths." } } div { class: "mb-16", style: "text-align: center;", button { class: "btn btn-primary", disabled: is_importing, onclick: move |_| on_scan.call(()), if is_importing { "Scanning..." } else { "Scan All Roots" } } } if let Some(ref progress) = scan_progress { { let pct = (progress.files_processed * 100) .checked_div(progress.files_found) .unwrap_or(0); rsx! { div { class: "mb-16", div { class: "progress-bar", div { class: "progress-fill", style: "width: {pct}%;" } } p { class: "text-muted text-sm", "{progress.files_processed} / {progress.files_found} files processed" } if progress.error_count > 0 { p { class: "text-muted text-sm", "{progress.error_count} errors" } } if progress.scanning { p { class: "text-muted text-sm", "Scanning..." } } else { p { class: "text-muted text-sm", "Scan complete" } } } } } } } } } } #[component] fn ImportOptions( tags: Vec, collections: Vec, selected_tags: Signal>, new_tags_input: Signal, selected_collection: Signal>, ) -> Element { let selected_tags = selected_tags; let mut new_tags_input = new_tags_input; let selected_collection = selected_collection; rsx! { div { class: "card mb-16", div { class: "card-header", h4 { class: "card-title", "Import Options" } } div { class: "form-group", label { class: "form-label", "Tags" } if tags.is_empty() { p { class: "text-muted text-sm", "No tags available. Create tags from the Tags page." } } else { div { class: "tag-list", for tag in tags.iter() { { let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); let is_selected = selected_tags.read().contains(&tag_id); let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; rsx! { span { class: "{badge_class}", onclick: { let tag_id = tag_id.clone(); let mut selected_tags = selected_tags; move |_| { let mut current = selected_tags.read().clone(); if let Some(pos) = current.iter().position(|t| t == &tag_id) { current.remove(pos); } else { current.push(tag_id.clone()); } selected_tags.set(current); } }, "{tag_name}" } } } } } } } div { class: "form-group", label { class: "form-label", "Create New Tags" } input { r#type: "text", placeholder: "tag1, tag2, tag3...", value: "{new_tags_input}", oninput: move |e| new_tags_input.set(e.value()), } p { class: "text-muted text-sm", "Comma-separated. Will be created if they don't exist." } } div { class: "form-group", label { class: "form-label", "Add to Collection" } select { value: "{selected_collection.read().clone().unwrap_or_default()}", onchange: { let mut selected_collection = selected_collection; move |e: Event| { let val = e.value(); if val.is_empty() { selected_collection.set(None); } else { selected_collection.set(Some(val)); } } }, option { value: "", "None" } for col in collections.iter() { { let col_id = col.id.clone(); let col_name = col.name.clone(); rsx! { option { value: "{col_id}", "{col_name}" } } } } } } } } } fn parse_new_tags(input: &str) -> Vec { input .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }