Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If5a21f16221f3c56a8008e139f93edc46a6a6964
824 lines
41 KiB
Rust
824 lines
41 KiB
Rust
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<String>, Vec<String>, Vec<String>, Option<String>);
|
|
|
|
#[component]
|
|
pub fn Import(
|
|
tags: Vec<TagResponse>,
|
|
collections: Vec<CollectionResponse>,
|
|
on_import_file: EventHandler<ImportEvent>,
|
|
on_import_directory: EventHandler<ImportEvent>,
|
|
on_import_batch: EventHandler<BatchImportEvent>,
|
|
on_scan: EventHandler<()>,
|
|
on_preview_directory: EventHandler<(String, bool)>,
|
|
preview_files: Vec<DirectoryPreviewFile>,
|
|
preview_total_size: u64,
|
|
scan_progress: Option<ScanStatusResponse>,
|
|
#[props(default = false)] is_importing: bool,
|
|
// Extended import state
|
|
#[props(default)] current_file: Option<String>,
|
|
#[props(default)] import_queue: Vec<String>,
|
|
#[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::<String>::new);
|
|
let new_tags_input = use_signal(String::new);
|
|
let selected_collection = use_signal(|| Option::<String>::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::<String>::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<String> = 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::<u64>() {
|
|
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::<u64>() {
|
|
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<String> = filtered_paths
|
|
.iter()
|
|
.cloned()
|
|
.collect();
|
|
let sel = selected_file_paths.read().clone();
|
|
let new_sel: HashSet<String> = 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<String> = 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<TagResponse>,
|
|
collections: Vec<CollectionResponse>,
|
|
selected_tags: Signal<Vec<String>>,
|
|
new_tags_input: Signal<String>,
|
|
selected_collection: Signal<Option<String>>,
|
|
) -> 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<FormData>| {
|
|
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<String> {
|
|
input
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
}
|