initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
commit
6a73d11c4b
124 changed files with 34856 additions and 0 deletions
717
crates/pinakes-ui/src/components/import.rs
Normal file
717
crates/pinakes-ui/src/components/import.rs
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
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>,
|
||||
) -> 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! {
|
||||
// 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",
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
"Import"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImportOptions {
|
||||
tags: tags.clone(),
|
||||
collections: collections.clone(),
|
||||
selected_tags: selected_tags,
|
||||
new_tags_input: new_tags_input,
|
||||
selected_collection: 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: "form-row",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *recursive.read(),
|
||||
onchange: move |_| recursive.toggle(),
|
||||
}
|
||||
span { style: "margin-left: 6px;", "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.iter().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();
|
||||
|
||||
// Read selection once for display
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
// Filter bar
|
||||
div { class: "filter-bar",
|
||||
div { class: "flex-row mb-8",
|
||||
label {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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: "flex-row",
|
||||
label { class: "form-label", "Min size (MB): " }
|
||||
input {
|
||||
r#type: "number",
|
||||
value: "{min / (1024 * 1024)}",
|
||||
oninput: move |e| {
|
||||
if let Ok(mb) = e.value().parse::<u64>() {
|
||||
filter_min_size.set(mb * 1024 * 1024);
|
||||
} else {
|
||||
filter_min_size.set(0);
|
||||
}
|
||||
},
|
||||
}
|
||||
label { class: "form-label", "Max size (MB): " }
|
||||
input {
|
||||
r#type: "number",
|
||||
value: "{max / (1024 * 1024)}",
|
||||
oninput: move |e| {
|
||||
if let Ok(mb) = e.value().parse::<u64>() {
|
||||
filter_max_size.set(mb * 1024 * 1024);
|
||||
} else {
|
||||
filter_max_size.set(0);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection toolbar
|
||||
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 {
|
||||
// Deselect all filtered
|
||||
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 {
|
||||
// Select all filtered
|
||||
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: selected_tags,
|
||||
new_tags_input: new_tags_input,
|
||||
selected_collection: 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,
|
||||
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 has_selected {
|
||||
"Import Selected ({sel_count})"
|
||||
} else {
|
||||
"Import Selected"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import entire directory
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
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());
|
||||
}
|
||||
}
|
||||
},
|
||||
"Import Entire Directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
onclick: move |_| on_scan.call(()),
|
||||
"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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue