pinakes: import in parallel; various UI improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
raf 2026-02-03 10:31:20 +03:00
commit 116fe7b059
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
42 changed files with 4316 additions and 316 deletions

View file

@ -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",