pinakes/crates/pinakes-ui/src/components/import.rs
NotAShelf 2f31242442
treewide: complete book management interface
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If5a21f16221f3c56a8008e139f93edc46a6a6964
2026-02-05 06:34:19 +03:00

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()
}