pinakes: import in parallel; various UI improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
parent
278bcaa4b0
commit
116fe7b059
42 changed files with 4316 additions and 316 deletions
|
|
@ -1,6 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use futures::future::join_all;
|
||||
|
||||
use crate::client::*;
|
||||
use crate::components::{
|
||||
|
|
@ -85,6 +87,9 @@ pub fn App() -> Element {
|
|||
let mut last_search_query = use_signal(String::new);
|
||||
let mut last_search_sort = use_signal(|| Option::<String>::None);
|
||||
|
||||
// Phase 3.6: Saved searches
|
||||
let mut saved_searches = use_signal(Vec::<SavedSearchResponse>::new);
|
||||
|
||||
// Phase 6.1: Audit pagination & filter
|
||||
let mut audit_page = use_signal(|| 0u64);
|
||||
let audit_page_size = use_signal(|| 200u64);
|
||||
|
|
@ -107,8 +112,44 @@ pub fn App() -> Element {
|
|||
let mut login_loading = use_signal(|| false);
|
||||
let mut auto_play_media = use_signal(|| false);
|
||||
|
||||
// Theme state (Phase 3.3)
|
||||
let mut current_theme = use_signal(|| "dark".to_string());
|
||||
let mut system_prefers_dark = use_signal(|| true);
|
||||
|
||||
// Detect system color scheme preference
|
||||
use_effect(move || {
|
||||
spawn(async move {
|
||||
// Check system preference using JavaScript
|
||||
let result =
|
||||
document::eval(r#"window.matchMedia('(prefers-color-scheme: dark)').matches"#);
|
||||
if let Ok(val) = result.await {
|
||||
if let Some(prefers_dark) = val.as_bool() {
|
||||
system_prefers_dark.set(prefers_dark);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Compute effective theme based on preference
|
||||
let effective_theme = use_memo(move || {
|
||||
let theme = current_theme.read().clone();
|
||||
if theme == "system" {
|
||||
if *system_prefers_dark.read() {
|
||||
"dark".to_string()
|
||||
} else {
|
||||
"light".to_string()
|
||||
}
|
||||
} else {
|
||||
theme
|
||||
}
|
||||
});
|
||||
|
||||
// Import state for UI feedback
|
||||
let mut import_in_progress = use_signal(|| false);
|
||||
// Extended import state: current file name, queue of pending imports, progress (completed, total)
|
||||
let mut import_current_file = use_signal(|| Option::<String>::None);
|
||||
let mut import_queue = use_signal(Vec::<String>::new);
|
||||
let mut import_progress = use_signal(|| (0usize, 0usize)); // (completed, total)
|
||||
|
||||
// Check auth on startup
|
||||
let client_auth = client.read().clone();
|
||||
|
|
@ -136,6 +177,7 @@ pub fn App() -> Element {
|
|||
if let Ok(cfg) = client.get_config().await {
|
||||
auto_play_media.set(cfg.ui.auto_play_media);
|
||||
sidebar_collapsed.set(cfg.ui.sidebar_collapsed);
|
||||
current_theme.set(cfg.ui.theme.clone());
|
||||
if cfg.ui.default_page_size > 0 {
|
||||
media_page_size.set(cfg.ui.default_page_size as u64);
|
||||
}
|
||||
|
|
@ -183,6 +225,10 @@ pub fn App() -> Element {
|
|||
if let Ok(c) = client.list_collections().await {
|
||||
collections_list.set(c);
|
||||
}
|
||||
// Phase 3.6: Load saved searches
|
||||
if let Ok(ss) = client.list_saved_searches().await {
|
||||
saved_searches.set(ss);
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -310,14 +356,17 @@ pub fn App() -> Element {
|
|||
} else {
|
||||
// Phase 7.1: Keyboard shortcuts
|
||||
div {
|
||||
class: "app",
|
||||
class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" },
|
||||
tabindex: "0",
|
||||
onkeydown: {
|
||||
move |evt: KeyboardEvent| {
|
||||
let key = evt.key();
|
||||
let ctrl = evt.modifiers().contains(Modifiers::CONTROL);
|
||||
let meta = evt.modifiers().contains(Modifiers::META);
|
||||
let shift = evt.modifiers().contains(Modifiers::SHIFT);
|
||||
|
||||
match key {
|
||||
// Escape - close modal/go back
|
||||
Key::Escape => {
|
||||
if *show_help.read() {
|
||||
show_help.set(false);
|
||||
|
|
@ -325,6 +374,7 @@ pub fn App() -> Element {
|
|||
current_view.set(View::Library);
|
||||
}
|
||||
}
|
||||
// / or Ctrl+K - focus search
|
||||
Key::Character(ref c) if c == "/" && !ctrl && !meta => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Search);
|
||||
|
|
@ -333,9 +383,43 @@ pub fn App() -> Element {
|
|||
evt.prevent_default();
|
||||
current_view.set(View::Search);
|
||||
}
|
||||
// ? - toggle help overlay
|
||||
Key::Character(ref c) if c == "?" && !ctrl && !meta => {
|
||||
show_help.toggle();
|
||||
}
|
||||
// Ctrl+, - open settings
|
||||
Key::Character(ref c) if c == "," && (ctrl || meta) => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Settings);
|
||||
}
|
||||
// Number keys 1-6 for quick view switching (without modifiers)
|
||||
Key::Character(ref c) if c == "1" && !ctrl && !meta && !shift => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Library);
|
||||
}
|
||||
Key::Character(ref c) if c == "2" && !ctrl && !meta && !shift => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Search);
|
||||
}
|
||||
Key::Character(ref c) if c == "3" && !ctrl && !meta && !shift => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Import);
|
||||
}
|
||||
Key::Character(ref c) if c == "4" && !ctrl && !meta && !shift => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Tags);
|
||||
}
|
||||
Key::Character(ref c) if c == "5" && !ctrl && !meta && !shift => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Collections);
|
||||
}
|
||||
Key::Character(ref c) if c == "6" && !ctrl && !meta && !shift => {
|
||||
evt.prevent_default();
|
||||
current_view.set(View::Audit);
|
||||
}
|
||||
// g then l - go to library (vim-style)
|
||||
// Could implement g-prefix commands in the future
|
||||
Key::Character(ref c) if c == "g" && !ctrl && !meta => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -492,6 +576,44 @@ pub fn App() -> Element {
|
|||
|
||||
div { class: "sidebar-spacer" }
|
||||
|
||||
// Show import progress in sidebar when not on import page
|
||||
if *import_in_progress.read() && *current_view.read() != View::Import {
|
||||
{
|
||||
let (completed, total) = *import_progress.read();
|
||||
let has_progress = total > 0;
|
||||
let pct = if total > 0 { (completed * 100) / total } else { 0 };
|
||||
let current = import_current_file.read().clone();
|
||||
let queue_len = import_queue.read().len();
|
||||
rsx! {
|
||||
div { class: "sidebar-import-progress",
|
||||
div { class: "sidebar-import-header",
|
||||
div { class: "status-dot checking" }
|
||||
span {
|
||||
if has_progress {
|
||||
"Importing {completed}/{total}"
|
||||
} else {
|
||||
"Importing..."
|
||||
}
|
||||
}
|
||||
if queue_len > 0 {
|
||||
span { class: "import-queue-badge", "+{queue_len}" }
|
||||
}
|
||||
}
|
||||
if let Some(ref file_name) = current {
|
||||
div { class: "sidebar-import-file", "{file_name}" }
|
||||
}
|
||||
div { class: "progress-bar",
|
||||
if has_progress {
|
||||
div { class: "progress-fill", style: "width: {pct}%;" }
|
||||
} else {
|
||||
div { class: "progress-fill indeterminate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar collapse toggle
|
||||
button {
|
||||
class: "sidebar-toggle",
|
||||
|
|
@ -867,6 +989,62 @@ pub fn App() -> Element {
|
|||
});
|
||||
}
|
||||
},
|
||||
// Phase 3.6: Saved searches
|
||||
saved_searches: saved_searches.read().clone(),
|
||||
on_save_search: {
|
||||
let client = client.read().clone();
|
||||
move |(name, query, sort): (String, String, Option<String>)| {
|
||||
let client = client.clone();
|
||||
spawn(async move {
|
||||
match client.create_saved_search(&name, &query, sort.as_deref()).await {
|
||||
Ok(ss) => {
|
||||
saved_searches.write().push(ss);
|
||||
show_toast(format!("Search '{}' saved", name), false);
|
||||
}
|
||||
Err(e) => show_toast(format!("Failed to save search: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
on_delete_saved_search: {
|
||||
let client = client.read().clone();
|
||||
move |id: String| {
|
||||
let client = client.clone();
|
||||
spawn(async move {
|
||||
match client.delete_saved_search(&id).await {
|
||||
Ok(_) => {
|
||||
saved_searches.write().retain(|s| s.id != id);
|
||||
show_toast("Search deleted".into(), false);
|
||||
}
|
||||
Err(e) => show_toast(format!("Failed to delete: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
on_load_saved_search: {
|
||||
let client = client.read().clone();
|
||||
move |ss: SavedSearchResponse| {
|
||||
let client = client.clone();
|
||||
let query = ss.query.clone();
|
||||
let sort = ss.sort_order.clone();
|
||||
search_page.set(0);
|
||||
last_search_query.set(query.clone());
|
||||
last_search_sort.set(sort.clone());
|
||||
spawn(async move {
|
||||
loading.set(true);
|
||||
let offset = 0;
|
||||
let limit = *search_page_size.read();
|
||||
match client.search(&query, sort.as_deref(), offset, limit).await {
|
||||
Ok(resp) => {
|
||||
search_total.set(resp.total_count);
|
||||
search_results.set(resp.items);
|
||||
}
|
||||
Err(e) => show_toast(format!("Search failed: {e}"), true),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
View::Detail => {
|
||||
|
|
@ -1225,10 +1403,54 @@ pub fn App() -> Element {
|
|||
let refresh_media = refresh_media.clone();
|
||||
let refresh_tags = refresh_tags.clone();
|
||||
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
|
||||
// Extract file name from path
|
||||
let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||
|
||||
// Check if already importing - if so, add to queue
|
||||
|
||||
|
||||
// Extract directory name from path
|
||||
|
||||
// Check if already importing - if so, add to queue
|
||||
if *import_in_progress.read() {
|
||||
|
||||
// Get preview files if available for per-file progress
|
||||
|
||||
// Use parallel import with per-batch progress
|
||||
|
||||
// Show first file in batch as current
|
||||
|
||||
// Process batch in parallel
|
||||
|
||||
// Update progress after batch
|
||||
|
||||
// Fallback: use server-side directory import (no per-file progress)
|
||||
// Check if already importing - if so, add to queue
|
||||
|
||||
// Update progress from scan status
|
||||
|
||||
// Check if already importing - if so, add to queue
|
||||
|
||||
// Process files in parallel batches for better performance
|
||||
|
||||
// Show first file in batch as current
|
||||
|
||||
// Process batch in parallel
|
||||
|
||||
// Update progress after batch
|
||||
|
||||
// Extended import state
|
||||
import_queue.write().push(file_name);
|
||||
show_toast("Added to import queue".into(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
let client = client.clone();
|
||||
let refresh_media = refresh_media.clone();
|
||||
let refresh_tags = refresh_tags.clone();
|
||||
import_in_progress.set(true);
|
||||
import_current_file.set(Some(file_name));
|
||||
import_progress.set((0, 1));
|
||||
spawn(async move {
|
||||
if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() {
|
||||
match client.import_file(&path).await {
|
||||
|
|
@ -1275,6 +1497,8 @@ pub fn App() -> Element {
|
|||
Err(e) => show_toast(format!("Import failed: {e}"), true),
|
||||
}
|
||||
}
|
||||
import_progress.set((1, 1));
|
||||
import_current_file.set(None);
|
||||
import_in_progress.set(false);
|
||||
});
|
||||
}
|
||||
|
|
@ -1284,45 +1508,169 @@ pub fn App() -> Element {
|
|||
let refresh_media = refresh_media.clone();
|
||||
let refresh_tags = refresh_tags.clone();
|
||||
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
|
||||
let dir_name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||
|
||||
if *import_in_progress.read() {
|
||||
import_queue.write().push(format!("{dir_name}/ (directory)"));
|
||||
show_toast("Added directory to import queue".into(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
let files_to_import: Vec<String> = preview_files
|
||||
.read()
|
||||
.iter()
|
||||
.map(|f| f.path.clone())
|
||||
.collect();
|
||||
|
||||
let client = client.clone();
|
||||
let refresh_media = refresh_media.clone();
|
||||
let refresh_tags = refresh_tags.clone();
|
||||
import_in_progress.set(true);
|
||||
spawn(async move {
|
||||
match client
|
||||
.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
show_toast(
|
||||
format!(
|
||||
"Done: {} imported, {} duplicates, {} errors",
|
||||
resp.imported,
|
||||
resp.duplicates,
|
||||
resp.errors,
|
||||
),
|
||||
resp.errors > 0,
|
||||
);
|
||||
refresh_media();
|
||||
if !new_tags.is_empty() {
|
||||
refresh_tags();
|
||||
|
||||
if !files_to_import.is_empty() {
|
||||
let file_count = files_to_import.len();
|
||||
import_progress.set((0, file_count));
|
||||
|
||||
let client = Arc::new(client);
|
||||
let tag_ids = Arc::new(tag_ids);
|
||||
let new_tags = Arc::new(new_tags);
|
||||
let col_id = Arc::new(col_id);
|
||||
|
||||
const BATCH_SIZE: usize = 6;
|
||||
spawn(async move {
|
||||
let imported = Arc::new(AtomicUsize::new(0));
|
||||
let duplicates = Arc::new(AtomicUsize::new(0));
|
||||
let errors = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
for chunk in files_to_import.chunks(BATCH_SIZE) {
|
||||
if let Some(first_path) = chunk.first() {
|
||||
let file_name = first_path
|
||||
|
||||
|
||||
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(first_path);
|
||||
import_current_file.set(Some(file_name.to_string()));
|
||||
}
|
||||
preview_files.set(Vec::new());
|
||||
preview_total_size.set(0);
|
||||
let futures: Vec<_> = chunk
|
||||
.iter()
|
||||
.map(|file_path| {
|
||||
let client = Arc::clone(&client);
|
||||
let tag_ids = Arc::clone(&tag_ids);
|
||||
let new_tags = Arc::clone(&new_tags);
|
||||
let col_id = Arc::clone(&col_id);
|
||||
let imported = Arc::clone(&imported);
|
||||
let duplicates = Arc::clone(&duplicates);
|
||||
let errors = Arc::clone(&errors);
|
||||
let completed = Arc::clone(&completed);
|
||||
let file_path = file_path.clone();
|
||||
async move {
|
||||
let result = if tag_ids.is_empty() && new_tags.is_empty()
|
||||
&& col_id.is_none()
|
||||
{
|
||||
client.import_file(&file_path).await
|
||||
} else {
|
||||
client
|
||||
.import_with_options(
|
||||
&file_path,
|
||||
&tag_ids,
|
||||
&new_tags,
|
||||
col_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if resp.was_duplicate {
|
||||
duplicates.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
imported.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
completed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
join_all(futures).await;
|
||||
let done = completed.load(Ordering::Relaxed);
|
||||
import_progress.set((done, file_count));
|
||||
}
|
||||
Err(e) => show_toast(format!("Directory import failed: {e}"), true),
|
||||
}
|
||||
import_in_progress.set(false);
|
||||
});
|
||||
let imported = imported.load(Ordering::Relaxed);
|
||||
let duplicates = duplicates.load(Ordering::Relaxed);
|
||||
let errors = errors.load(Ordering::Relaxed);
|
||||
show_toast(
|
||||
format!(
|
||||
"Done: {imported} imported, {duplicates} duplicates, {errors} errors",
|
||||
),
|
||||
errors > 0,
|
||||
);
|
||||
refresh_media();
|
||||
if !new_tags.is_empty() {
|
||||
refresh_tags();
|
||||
}
|
||||
preview_files.set(Vec::new());
|
||||
preview_total_size.set(0);
|
||||
import_progress.set((file_count, file_count));
|
||||
import_current_file.set(None);
|
||||
import_in_progress.set(false);
|
||||
});
|
||||
} else {
|
||||
import_current_file.set(Some(format!("{dir_name}/")));
|
||||
import_progress.set((0, 0));
|
||||
spawn(async move {
|
||||
match client
|
||||
.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
show_toast(
|
||||
format!(
|
||||
"Done: {} imported, {} duplicates, {} errors",
|
||||
resp.imported,
|
||||
resp.duplicates,
|
||||
resp.errors,
|
||||
),
|
||||
resp.errors > 0,
|
||||
);
|
||||
refresh_media();
|
||||
if !new_tags.is_empty() {
|
||||
refresh_tags();
|
||||
}
|
||||
preview_files.set(Vec::new());
|
||||
preview_total_size.set(0);
|
||||
}
|
||||
Err(e) => {
|
||||
show_toast(format!("Directory import failed: {e}"), true)
|
||||
}
|
||||
}
|
||||
import_current_file.set(None);
|
||||
import_progress.set((0, 0));
|
||||
import_in_progress.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
on_scan: {
|
||||
let client = client.read().clone();
|
||||
let refresh_media = refresh_media.clone();
|
||||
move |_| {
|
||||
if *import_in_progress.read() {
|
||||
import_queue.write().push("Scan roots".to_string());
|
||||
show_toast("Added scan to import queue".into(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
let client = client.clone();
|
||||
let refresh_media = refresh_media.clone();
|
||||
import_in_progress.set(true);
|
||||
import_current_file.set(Some("Scanning roots...".to_string()));
|
||||
import_progress.set((0, 0)); // Will be updated from scan_progress
|
||||
spawn(async move {
|
||||
match client.trigger_scan().await {
|
||||
Ok(_results) => {
|
||||
|
|
@ -1330,6 +1678,23 @@ pub fn App() -> Element {
|
|||
match client.scan_status().await {
|
||||
Ok(status) => {
|
||||
let done = !status.scanning;
|
||||
import_progress
|
||||
.set((
|
||||
status.files_processed as usize,
|
||||
status.files_found as usize,
|
||||
));
|
||||
if status.files_found > 0 {
|
||||
import_current_file
|
||||
.set(
|
||||
Some(
|
||||
format!(
|
||||
"Scanning ({}/{})",
|
||||
status.files_processed,
|
||||
status.files_found,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
scan_progress.set(Some(status.clone()));
|
||||
if done {
|
||||
let total = status.files_processed;
|
||||
|
|
@ -1348,6 +1713,8 @@ pub fn App() -> Element {
|
|||
}
|
||||
Err(e) => show_toast(format!("Scan failed: {e}"), true),
|
||||
}
|
||||
import_current_file.set(None);
|
||||
import_progress.set((0, 0));
|
||||
import_in_progress.set(false);
|
||||
});
|
||||
}
|
||||
|
|
@ -1357,40 +1724,105 @@ pub fn App() -> Element {
|
|||
let refresh_media = refresh_media.clone();
|
||||
let refresh_tags = refresh_tags.clone();
|
||||
move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| {
|
||||
let client = client.clone();
|
||||
let file_count = paths.len();
|
||||
|
||||
if *import_in_progress.read() {
|
||||
import_queue.write().push(format!("{file_count} files (batch)"));
|
||||
show_toast("Added batch to import queue".into(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
let client = Arc::new(client.clone());
|
||||
let refresh_media = refresh_media.clone();
|
||||
let refresh_tags = refresh_tags.clone();
|
||||
let file_count = paths.len();
|
||||
let tag_ids = Arc::new(tag_ids);
|
||||
let new_tags = Arc::new(new_tags);
|
||||
let col_id = Arc::new(col_id);
|
||||
import_in_progress.set(true);
|
||||
import_progress.set((0, file_count));
|
||||
|
||||
const BATCH_SIZE: usize = 6;
|
||||
spawn(async move {
|
||||
match client
|
||||
.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
show_toast(
|
||||
format!(
|
||||
"Done: {} imported, {} duplicates, {} errors",
|
||||
resp.imported,
|
||||
resp.duplicates,
|
||||
resp.errors,
|
||||
),
|
||||
resp.errors > 0,
|
||||
);
|
||||
refresh_media();
|
||||
if !new_tags.is_empty() {
|
||||
refresh_tags();
|
||||
}
|
||||
preview_files.set(Vec::new());
|
||||
preview_total_size.set(0);
|
||||
}
|
||||
Err(e) => {
|
||||
show_toast(
|
||||
format!("Batch import failed ({file_count} files): {e}"),
|
||||
true,
|
||||
)
|
||||
let imported = Arc::new(AtomicUsize::new(0));
|
||||
let duplicates = Arc::new(AtomicUsize::new(0));
|
||||
let errors = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
for chunk in paths.chunks(BATCH_SIZE) {
|
||||
if let Some(first_path) = chunk.first() {
|
||||
let file_name = first_path
|
||||
|
||||
|
||||
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(first_path);
|
||||
import_current_file.set(Some(file_name.to_string()));
|
||||
}
|
||||
let futures: Vec<_> = chunk
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let client = Arc::clone(&client);
|
||||
let tag_ids = Arc::clone(&tag_ids);
|
||||
let new_tags = Arc::clone(&new_tags);
|
||||
let col_id = Arc::clone(&col_id);
|
||||
let imported = Arc::clone(&imported);
|
||||
let duplicates = Arc::clone(&duplicates);
|
||||
let errors = Arc::clone(&errors);
|
||||
let completed = Arc::clone(&completed);
|
||||
let path = path.clone();
|
||||
async move {
|
||||
let result = if tag_ids.is_empty() && new_tags.is_empty()
|
||||
&& col_id.is_none()
|
||||
{
|
||||
client.import_file(&path).await
|
||||
} else {
|
||||
client
|
||||
.import_with_options(
|
||||
&path,
|
||||
&tag_ids,
|
||||
&new_tags,
|
||||
col_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if resp.was_duplicate {
|
||||
duplicates.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
imported.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
completed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
join_all(futures).await;
|
||||
let done = completed.load(Ordering::Relaxed);
|
||||
import_progress.set((done, file_count));
|
||||
}
|
||||
let imported = imported.load(Ordering::Relaxed);
|
||||
let duplicates = duplicates.load(Ordering::Relaxed);
|
||||
let errors = errors.load(Ordering::Relaxed);
|
||||
show_toast(
|
||||
format!(
|
||||
"Done: {imported} imported, {duplicates} duplicates, {errors} errors",
|
||||
),
|
||||
errors > 0,
|
||||
);
|
||||
refresh_media();
|
||||
if !new_tags.is_empty() {
|
||||
refresh_tags();
|
||||
}
|
||||
preview_files.set(Vec::new());
|
||||
preview_total_size.set(0);
|
||||
import_progress.set((file_count, file_count));
|
||||
import_current_file.set(None);
|
||||
import_in_progress.set(false);
|
||||
});
|
||||
}
|
||||
|
|
@ -1416,6 +1848,9 @@ pub fn App() -> Element {
|
|||
},
|
||||
preview_files: preview_files.read().clone(),
|
||||
preview_total_size: *preview_total_size.read(),
|
||||
current_file: import_current_file.read().clone(),
|
||||
import_queue: import_queue.read().clone(),
|
||||
import_progress: *import_progress.read(),
|
||||
}
|
||||
},
|
||||
View::Database => {
|
||||
|
|
@ -1620,6 +2055,7 @@ pub fn App() -> Element {
|
|||
Ok(ui_cfg) => {
|
||||
auto_play_media.set(ui_cfg.auto_play_media);
|
||||
sidebar_collapsed.set(ui_cfg.sidebar_collapsed);
|
||||
current_theme.set(ui_cfg.theme.clone());
|
||||
if let Ok(cfg) = client.get_config().await {
|
||||
config_data.set(Some(cfg));
|
||||
}
|
||||
|
|
@ -1654,6 +2090,7 @@ pub fn App() -> Element {
|
|||
onclick: move |evt: MouseEvent| evt.stop_propagation(),
|
||||
h3 { "Keyboard Shortcuts" }
|
||||
div { class: "help-shortcuts",
|
||||
h4 { "Navigation" }
|
||||
div { class: "shortcut-row",
|
||||
kbd { "Esc" }
|
||||
span { "Go back / close overlay" }
|
||||
|
|
@ -1664,12 +2101,42 @@ pub fn App() -> Element {
|
|||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "Ctrl+K" }
|
||||
span { "Focus search" }
|
||||
span { "Focus search (alternative)" }
|
||||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "Ctrl+," }
|
||||
span { "Open settings" }
|
||||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "?" }
|
||||
span { "Toggle this help" }
|
||||
}
|
||||
|
||||
h4 { "Quick Views" }
|
||||
div { class: "shortcut-row",
|
||||
kbd { "1" }
|
||||
span { "Library" }
|
||||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "2" }
|
||||
span { "Search" }
|
||||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "3" }
|
||||
span { "Import" }
|
||||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "4" }
|
||||
span { "Tags" }
|
||||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "5" }
|
||||
span { "Collections" }
|
||||
}
|
||||
div { class: "shortcut-row",
|
||||
kbd { "6" }
|
||||
span { "Audit Log" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "help-close",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue