use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use dioxus::prelude::*; use futures::future::join_all; use crate::client::*; use crate::components::{ audit, collections, database, detail, duplicates, graph_view, import, library, media_player::PlayQueue, search, settings, statistics, tags, tasks, }; // Login component available via crate::components::login when auth gating is needed use crate::styles; static TOAST_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); #[derive(Debug, Clone, PartialEq)] enum View { Library, Search, Detail, Tags, Collections, Audit, Import, Duplicates, Statistics, Tasks, Settings, Database, Graph, } impl View { fn title(&self) -> &'static str { match self { Self::Library => "Library", Self::Search => "Search", Self::Detail => "Detail", Self::Tags => "Tags", Self::Collections => "Collections", Self::Audit => "Audit Log", Self::Import => "Import", Self::Duplicates => "Duplicates", Self::Statistics => "Statistics", Self::Tasks => "Tasks", Self::Settings => "Settings", Self::Database => "Database", Self::Graph => "Note Graph", } } } #[component] pub fn App() -> Element { // Phase 1.3: Auth support let base_url = std::env::var("PINAKES_SERVER_URL").unwrap_or_else(|_| "http://localhost:3000".into()); let api_key = std::env::var("PINAKES_API_KEY").ok(); let mut client = use_signal(|| ApiClient::new(&base_url, api_key.as_deref())); let server_url = use_signal(|| base_url.clone()); let mut current_view = use_signal(|| View::Library); let mut media_list = use_signal(Vec::::new); let mut media_total_count = use_signal(|| 0u64); let mut media_page = use_signal(|| 0u64); let mut media_page_size = use_signal(|| 48u64); let mut media_sort = use_signal(|| "created_at_desc".to_string()); let mut search_results = use_signal(Vec::::new); let mut search_total = use_signal(|| 0u64); let mut selected_media = use_signal(|| Option::::None); let mut media_tags = use_signal(Vec::::new); let mut tags_list = use_signal(Vec::::new); let mut collections_list = use_signal(Vec::::new); let mut audit_list = use_signal(Vec::::new); let mut config_data = use_signal(|| Option::::None); let mut db_stats = use_signal(|| Option::::None); let mut library_stats = use_signal(|| Option::::None); let mut scheduled_tasks = use_signal(Vec::::new); let mut duplicate_groups = use_signal(Vec::::new); let mut preview_files = use_signal(Vec::::new); let mut preview_total_size = use_signal(|| 0u64); let mut viewing_collection = use_signal(|| Option::::None); let mut collection_members = use_signal(Vec::::new); let mut server_connected = use_signal(|| false); let mut server_checking = use_signal(|| true); let mut loading = use_signal(|| true); let mut load_error = use_signal(|| Option::::None); // Phase 1.4: Toast queue let mut toast_queue = use_signal(Vec::<(String, bool, usize)>::new); // Phase 5.1: Search pagination let mut search_page = use_signal(|| 0u64); let search_page_size = use_signal(|| 50u64); let mut last_search_query = use_signal(String::new); let mut last_search_sort = use_signal(|| Option::::None); // Phase 3.6: Saved searches let mut saved_searches = use_signal(Vec::::new); // Phase 6.1: Audit pagination & filter let mut audit_page = use_signal(|| 0u64); let audit_page_size = use_signal(|| 200u64); let audit_total_count = use_signal(|| 0u64); let mut audit_filter = use_signal(|| "All".to_string()); // Phase 6.2: Scan progress let mut scan_progress = use_signal(|| Option::::None); // Phase 7.1: Help overlay let mut show_help = use_signal(|| false); // Phase 8: Sidebar collapse let mut sidebar_collapsed = use_signal(|| false); // Auth state let mut auth_required = use_signal(|| false); let mut current_user = use_signal(|| Option::::None); let mut login_error = use_signal(|| Option::::None); let mut login_loading = use_signal(|| false); let mut auto_play_media = use_signal(|| false); let mut play_queue = use_signal(PlayQueue::default); // 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 && 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::::None); let mut import_queue = use_signal(Vec::::new); let mut import_progress = use_signal(|| (0usize, 0usize)); // (completed, total) // Check auth on startup let client_auth = client.read().clone(); use_effect(move || { let client = client_auth.clone(); spawn(async move { match client.get_current_user().await { Ok(user) => { current_user.set(Some(user)); auth_required.set(false); } Err(e) => { // Check if this is an auth error (401) vs network error let err_str = e.to_string(); if err_str.contains("401") || err_str.contains("unauthorized") || err_str.contains("Unauthorized") { auth_required.set(true); } // For network errors, don't require auth (server offline state handles this) } } // Load UI config 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); } config_data.set(Some(cfg)); } }); }); // Health check polling let client_health = client.read().clone(); use_effect(move || { let client = client_health.clone(); spawn(async move { loop { server_checking.set(true); let ok = client.health_check().await; server_connected.set(ok); server_checking.set(false); tokio::time::sleep(std::time::Duration::from_secs(10)).await; } }); }); // Load initial data (Phase 2.2: pass sort to list_media) let client_init = client.read().clone(); let init_sort = media_sort.read().clone(); use_effect(move || { let client = client_init.clone(); let sort = init_sort.clone(); spawn(async move { loading.set(true); load_error.set(None); match client.list_media(0, 48, Some(&sort)).await { Ok(items) => media_list.set(items), Err(e) => { load_error.set(Some(format!("Failed to load media: {e}"))); } } if let Ok(count) = client.get_media_count().await { media_total_count.set(count); } if let Ok(t) = client.list_tags().await { tags_list.set(t); } 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); }); }); // Phase 1.4: Toast helper with queue support let mut show_toast = move |msg: String, is_error: bool| { let id = TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed); toast_queue.write().push((msg, is_error, id)); // Keep at most 3 toasts let len = toast_queue.read().len(); if len > 3 { toast_queue.write().drain(0..len - 3); } spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(3)).await; toast_queue.write().retain(|(_, _, tid)| *tid != id); }); }; // Helper: refresh media list with current pagination (Phase 2.2: pass sort) let refresh_media = { let client = client.read().clone(); move || { let client = client.clone(); spawn(async move { loading.set(true); let offset = *media_page.read() * *media_page_size.read(); let limit = *media_page_size.read(); let sort = media_sort.read().clone(); if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { media_list.set(items); } if let Ok(count) = client.get_media_count().await { media_total_count.set(count); } loading.set(false); }); } }; // Helper: refresh tags let refresh_tags = { let client = client.read().clone(); move || { let client = client.clone(); spawn(async move { if let Ok(t) = client.list_tags().await { tags_list.set(t); } }); } }; // Helper: refresh collections let refresh_collections = { let client = client.read().clone(); move || { let client = client.clone(); spawn(async move { if let Ok(c) = client.list_collections().await { collections_list.set(c); } }); } }; // Helper: refresh audit with pagination and filter (Phase 6.1) let refresh_audit = { let client = client.read().clone(); move || { let client = client.clone(); spawn(async move { let offset = *audit_page.read() * *audit_page_size.read(); let limit = *audit_page_size.read(); if let Ok(entries) = client.list_audit(offset, limit).await { audit_list.set(entries); } }); } }; // Login handler for auth flow let on_login_submit = { move |(username, password): (String, String)| { let login_client = client.read().clone(); spawn(async move { login_loading.set(true); login_error.set(None); match login_client.login(&username, &password).await { Ok(resp) => { // Update the signal with a new client that has the token set client.write().set_token(&resp.token); current_user.set(Some(UserInfoResponse { username: resp.username, role: resp.role, })); auth_required.set(false); } Err(e) => { login_error.set(Some(format!("Login failed: {e}"))); } } login_loading.set(false); }); } }; let view_title = use_memo(move || current_view.read().title()); let _total_pages = use_memo(move || { let ps = *media_page_size.read(); let tc = *media_total_count.read(); if ps > 0 { tc.div_ceil(ps) } else { 1 } }); rsx! { style { {styles::CSS} } if *auth_required.read() { crate::components::login::Login { on_login: on_login_submit, error: login_error.read().clone(), loading: *login_loading.read(), } } else { // Phase 7.1: Keyboard shortcuts div { 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); } else if *current_view.read() == View::Detail { 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); } Key::Character(ref c) if c == "k" && (ctrl || meta) => { 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 => {} _ => {} } } }, // Sidebar div { class: if *sidebar_collapsed.read() { "sidebar collapsed" } else { "sidebar" }, div { class: "sidebar-header", span { class: "logo", "Pinakes" } span { class: "version", "v0.1" } } div { class: "nav-section", div { class: "nav-label", "Media" } button { class: if *current_view.read() == View::Library { "nav-item active" } else { "nav-item" }, onclick: { let refresh_media = refresh_media.clone(); move |_| { current_view.set(View::Library); refresh_media(); } }, span { class: "nav-icon", "\u{25a6}" } span { class: "nav-item-text", "Library" } // Phase 7.2: Badge span { class: "nav-badge", "{media_total_count}" } } button { class: if *current_view.read() == View::Search { "nav-item active" } else { "nav-item" }, onclick: move |_| current_view.set(View::Search), span { class: "nav-icon", "\u{2315}" } span { class: "nav-item-text", "Search" } } button { class: if *current_view.read() == View::Import { "nav-item active" } else { "nav-item" }, onclick: { let refresh_tags = refresh_tags.clone(); let refresh_collections = refresh_collections.clone(); move |_| { current_view.set(View::Import); preview_files.set(Vec::new()); preview_total_size.set(0); scan_progress.set(None); refresh_tags(); refresh_collections(); } }, span { class: "nav-icon", "\u{2912}" } span { class: "nav-item-text", "Import" } } } div { class: "nav-section", div { class: "nav-label", "Organize" } button { class: if *current_view.read() == View::Tags { "nav-item active" } else { "nav-item" }, onclick: { let refresh_tags = refresh_tags.clone(); move |_| { current_view.set(View::Tags); refresh_tags(); } }, span { class: "nav-icon", "\u{2605}" } span { class: "nav-item-text", "Tags" } // Phase 7.2: Badge span { class: "nav-badge", "{tags_list.read().len()}" } } button { class: if *current_view.read() == View::Collections { "nav-item active" } else { "nav-item" }, onclick: { let refresh_collections = refresh_collections.clone(); move |_| { current_view.set(View::Collections); viewing_collection.set(None); collection_members.set(Vec::new()); refresh_collections(); } }, span { class: "nav-icon", "\u{2630}" } span { class: "nav-item-text", "Collections" } // Phase 7.2: Badge span { class: "nav-badge", "{collections_list.read().len()}" } } } div { class: "nav-section", div { class: "nav-label", "System" } button { class: if *current_view.read() == View::Audit { "nav-item active" } else { "nav-item" }, onclick: { let refresh_audit = refresh_audit.clone(); move |_| { current_view.set(View::Audit); refresh_audit(); } }, span { class: "nav-icon", "\u{2637}" } span { class: "nav-item-text", "Audit" } } button { class: if *current_view.read() == View::Duplicates { "nav-item active" } else { "nav-item" }, onclick: { let client = client.read().clone(); move |_| { current_view.set(View::Duplicates); let client = client.clone(); spawn(async move { if let Ok(groups) = client.list_duplicates().await { duplicate_groups.set(groups); } }); } }, span { class: "nav-icon", "\u{2261}" } span { class: "nav-item-text", "Duplicates" } } button { class: if *current_view.read() == View::Statistics { "nav-item active" } else { "nav-item" }, onclick: { let client = client.read().clone(); move |_| { current_view.set(View::Statistics); let client = client.clone(); spawn(async move { if let Ok(stats) = client.library_statistics().await { library_stats.set(Some(stats)); } }); } }, span { class: "nav-icon", "\u{1f4ca}" } span { class: "nav-item-text", "Statistics" } } button { class: if *current_view.read() == View::Graph { "nav-item active" } else { "nav-item" }, onclick: move |_| { current_view.set(View::Graph); }, span { class: "nav-icon", "\u{1f578}" } span { class: "nav-item-text", "Graph" } } button { class: if *current_view.read() == View::Tasks { "nav-item active" } else { "nav-item" }, onclick: { let client = client.read().clone(); move |_| { current_view.set(View::Tasks); let client = client.clone(); spawn(async move { if let Ok(tasks_data) = client.list_scheduled_tasks().await { scheduled_tasks.set(tasks_data); } }); } }, span { class: "nav-icon", "\u{23f0}" } span { class: "nav-item-text", "Tasks" } } button { class: if *current_view.read() == View::Settings { "nav-item active" } else { "nav-item" }, onclick: { let client = client.read().clone(); move |_| { current_view.set(View::Settings); let client = client.clone(); spawn(async move { if let Ok(cfg) = client.get_config().await { config_data.set(Some(cfg)); } }); } }, span { class: "nav-icon", "\u{2699}" } span { class: "nav-item-text", "Settings" } } button { class: if *current_view.read() == View::Database { "nav-item active" } else { "nav-item" }, onclick: { let client = client.read().clone(); move |_| { current_view.set(View::Database); let client = client.clone(); spawn(async move { if let Ok(stats) = client.database_stats().await { db_stats.set(Some(stats)); } }); } }, span { class: "nav-icon", "\u{2750}" } span { class: "nav-item-text", "Database" } } } 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 = (completed * 100).checked_div(total).unwrap_or(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", onclick: move |_| sidebar_collapsed.toggle(), if *sidebar_collapsed.read() { "\u{25b6}" } else { "\u{25c0}" } } // User info (when logged in) if let Some(ref user) = *current_user.read() { div { class: "sidebar-footer user-info", span { class: "user-name", "{user.username}" } span { class: "role-badge role-{user.role}", "{user.role}" } button { class: "btn btn-ghost btn-sm", onclick: { let client = client.read().clone(); move |_| { let client = client.clone(); spawn(async move { let _ = client.logout().await; current_user.set(None); auth_required.set(true); }); } }, "Logout" } } } // Server status indicator div { class: "sidebar-footer", div { class: "status-indicator", { let is_checking = *server_checking.read(); let is_connected = *server_connected.read(); let dot_class = if is_checking { "status-dot checking" } else if is_connected { "status-dot connected" } else { "status-dot disconnected" }; let label = if is_checking { "Checking..." } else if is_connected { "Server connected" } else { "Server offline" }; rsx! { span { class: "{dot_class}" } span { class: "status-text", "{label}" } } } } } } // Main content div { class: "main", div { class: "header", span { class: "page-title", "{view_title}" } div { class: "header-spacer" } } div { class: "content", // Offline banner if !*server_checking.read() && !*server_connected.read() { div { class: "offline-banner", span { class: "offline-icon", "\u{26a0}" } "Cannot reach the server. Make sure pinakes-server is running." } } // Error banner if let Some(ref err) = *load_error.read() { div { class: "error-banner", span { class: "error-icon", "\u{26a0}" } "{err}" } } // Loading indicator if *loading.read() { div { class: "loading-overlay", div { class: "spinner" } "Loading..." } } { // Phase 2.2: Sort wiring - actually refetch with sort // Phase 4.1 + 4.2: Search improvements // Phase 3.1 + 3.2: Detail view enhancements // Phase 3.2: Delete from detail navigates back and refreshes // Phase 5.1: Tags on_delete - confirmation handled inside Tags component // Phase 5.2: Collections enhancements // Phase 5.2: Navigate to detail when clicking a collection member // Phase 5.2: Add member to collection // Phase 6.1: Audit improvements // Phase 6.2: Scan progress // Phase 6.2: Scan with polling for progress // Poll scan status until done // Refresh duplicates list // Reload full config match *current_view.read() { View::Library => rsx! { div { class: "stats-grid", div { class: "stat-card", div { class: "stat-value", "{media_total_count}" } div { class: "stat-label", "Media Files" } } div { class: "stat-card", div { class: "stat-value", "{tags_list.read().len()}" } div { class: "stat-label", "Tags" } } div { class: "stat-card", div { class: "stat-value", "{collections_list.read().len()}" } div { class: "stat-label", "Collections" } } } library::Library { media: media_list.read().clone(), tags: tags_list.read().clone(), collections: collections_list.read().clone(), total_count: *media_total_count.read(), current_page: *media_page.read(), page_size: *media_page_size.read(), server_url: server_url.read().clone(), on_select: { let client = client.read().clone(); move |id: String| { let client = client.clone(); spawn(async move { match client.get_media(&id).await { Ok(item) => { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); selected_media.set(Some(item)); current_view.set(View::Detail); } Err(e) => show_toast(format!("Failed to load: {e}"), true), } }); } }, on_delete: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); move |id: String| { let client = client.clone(); let refresh_media = refresh_media.clone(); spawn(async move { match client.delete_media(&id).await { Ok(_) => { show_toast("Media deleted".into(), false); refresh_media(); } Err(e) => show_toast(format!("Delete failed: {e}"), true), } }); } }, on_batch_delete: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); move |ids: Vec| { let client = client.clone(); let refresh_media = refresh_media.clone(); spawn(async move { match client.batch_delete(&ids).await { Ok(resp) => { show_toast(format!("Deleted {} items", resp.processed), false); refresh_media(); } Err(e) => show_toast(format!("Batch delete failed: {e}"), true), } }); } }, on_batch_tag: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); move |(ids, tag_ids): (Vec, Vec)| { let client = client.clone(); let refresh_media = refresh_media.clone(); spawn(async move { match client.batch_tag(&ids, &tag_ids).await { Ok(resp) => { show_toast(format!("Tagged {} items", resp.processed), false); refresh_media(); } Err(e) => show_toast(format!("Batch tag failed: {e}"), true), } }); } }, on_batch_collection: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); move |(ids, col_id): (Vec, String)| { let client = client.clone(); let refresh_media = refresh_media.clone(); spawn(async move { match client.batch_add_to_collection(&ids, &col_id).await { Ok(resp) => { show_toast( format!("Added {} items to collection", resp.processed), false, ); refresh_media(); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_page_change: { let client = client.read().clone(); move |page: u64| { media_page.set(page); let client = client.clone(); spawn(async move { loading.set(true); let offset = page * *media_page_size.read(); let limit = *media_page_size.read(); let sort = media_sort.read().clone(); if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { media_list.set(items); } loading.set(false); }); } }, on_page_size_change: { let client = client.read().clone(); move |size: u64| { media_page_size.set(size); media_page.set(0); let client = client.clone(); spawn(async move { loading.set(true); let sort = media_sort.read().clone(); if let Ok(items) = client.list_media(0, size, Some(&sort)).await { media_list.set(items); } loading.set(false); }); } }, on_sort_change: { let client = client.read().clone(); move |sort: String| { media_sort.set(sort.clone()); media_page.set(0); let client = client.clone(); spawn(async move { loading.set(true); let limit = *media_page_size.read(); if let Ok(items) = client.list_media(0, limit, Some(&sort)).await { media_list.set(items); } loading.set(false); }); } }, on_select_all_global: { let client = client.read().clone(); move |callback: EventHandler>| { let client = client.clone(); spawn(async move { let total = *media_total_count.read(); let sort = media_sort.read().clone(); match client.list_media(0, total, Some(&sort)).await { Ok(items) => { let all_ids: Vec = items .iter() .map(|m| m.id.clone()) .collect(); callback.call(all_ids); } Err(e) => show_toast(format!("Failed to select all: {e}"), true), } }); } }, on_delete_all: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); move |_: ()| { let client = client.clone(); let refresh_media = refresh_media.clone(); spawn(async move { match client.delete_all_media().await { Ok(resp) => { show_toast(format!("Deleted {} items", resp.processed), false); refresh_media(); } Err(e) => show_toast(format!("Delete all failed: {e}"), true), } }); } }, } }, View::Search => rsx! { search::Search { results: search_results.read().clone(), total_count: *search_total.read(), search_page: *search_page.read(), page_size: *search_page_size.read(), server_url: server_url.read().clone(), on_search: { let client = client.read().clone(); move |(q, sort): (String, Option)| { let client = client.clone(); search_page.set(0); last_search_query.set(q.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(&q, 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); }); } }, on_select: { let client = client.read().clone(); move |id: String| { let client = client.clone(); spawn(async move { if let Ok(item) = client.get_media(&id).await { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); selected_media.set(Some(item)); current_view.set(View::Detail); } }); } }, on_page_change: { let client = client.read().clone(); move |page: u64| { search_page.set(page); let client = client.clone(); spawn(async move { loading.set(true); let offset = page * *search_page_size.read(); let limit = *search_page_size.read(); let q = last_search_query.read().clone(); let sort = last_search_sort.read().clone(); match client.search(&q, 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); }); } }, // 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)| { 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 => { let media_ref = selected_media.read(); match media_ref.as_ref() { Some(media) => rsx! { detail::Detail { media: media.clone(), media_tags: media_tags.read().clone(), all_tags: tags_list.read().clone(), server_url: server_url.read().clone(), autoplay: *auto_play_media.read(), on_back: move |_| current_view.set(View::Library), on_open: { let client = client.read().clone(); move |id: String| { let client = client.clone(); spawn(async move { match client.open_media(&id).await { Ok(_) => show_toast("File opened".into(), false), Err(e) => show_toast(format!("Open failed: {e}"), true), } }); } }, on_update: { let client = client.read().clone(); move |event: MediaUpdateEvent| { let client = client.clone(); spawn(async move { match client.update_media(&event).await { Ok(updated) => { selected_media.set(Some(updated)); show_toast("Metadata updated".into(), false); } Err(e) => show_toast(format!("Update failed: {e}"), true), } }); } }, on_tag: { let client = client.read().clone(); move |(media_id, tag_id): (String, String)| { let client = client.clone(); spawn(async move { match client.tag_media(&media_id, &tag_id).await { Ok(_) => { if let Ok(mtags) = client.get_media_tags(&media_id).await { media_tags.set(mtags); } show_toast("Tag added".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_untag: { let client = client.read().clone(); move |(media_id, tag_id): (String, String)| { let client = client.clone(); spawn(async move { match client.untag_media(&media_id, &tag_id).await { Ok(_) => { if let Ok(mtags) = client.get_media_tags(&media_id).await { media_tags.set(mtags); } show_toast("Tag removed".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_set_custom_field: { let client = client.read().clone(); move |(media_id, name, field_type, value): (String, String, String, String)| { let client = client.clone(); spawn(async move { match client .set_custom_field(&media_id, &name, &field_type, &value) .await { Ok(_) => { if let Ok(updated) = client.get_media(&media_id).await { selected_media.set(Some(updated)); } show_toast("Field added".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_delete_custom_field: { let client = client.read().clone(); move |(media_id, name): (String, String)| { let client = client.clone(); spawn(async move { match client.delete_custom_field(&media_id, &name).await { Ok(_) => { if let Ok(updated) = client.get_media(&media_id).await { selected_media.set(Some(updated)); } show_toast("Field removed".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_delete: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); move |id: String| { let client = client.clone(); let refresh_media = refresh_media.clone(); spawn(async move { match client.delete_media(&id).await { Ok(_) => { show_toast("Media deleted".into(), false); selected_media.set(None); current_view.set(View::Library); refresh_media(); } Err(e) => show_toast(format!("Delete failed: {e}"), true), } }); } }, play_queue: if play_queue.read().is_empty() { None } else { Some(play_queue.read().clone()) }, on_queue_select: { move |idx: usize| { let mut q = play_queue.write(); q.current_index = idx; // Update selected_media to the item at this index if let Some(item) = q.items.get(idx) { let media_id = item.media_id.clone(); let client = client.read().clone(); spawn(async move { if let Ok(media) = client.get_media(&media_id).await { selected_media.set(Some(media)); auto_play_media.set(true); } }); } } }, on_queue_remove: { move |idx: usize| { play_queue.write().remove(idx); } }, on_queue_clear: { move |_| { play_queue.write().clear(); } }, on_queue_toggle_repeat: { move |_| { play_queue.write().toggle_repeat(); } }, on_queue_toggle_shuffle: { move |_| { play_queue.write().toggle_shuffle(); } }, on_queue_next: { move |_| { let mut q = play_queue.write(); if let Some(item) = q.next() { let media_id = item.media_id.clone(); drop(q); let client = client.read().clone(); spawn(async move { if let Ok(media) = client.get_media(&media_id).await { selected_media.set(Some(media)); auto_play_media.set(true); } }); } } }, on_queue_previous: { move |_| { let mut q = play_queue.write(); if let Some(item) = q.previous() { let media_id = item.media_id.clone(); drop(q); let client = client.read().clone(); spawn(async move { if let Ok(media) = client.get_media(&media_id).await { selected_media.set(Some(media)); auto_play_media.set(true); } }); } } }, on_track_ended: { move |_| { let mut q = play_queue.write(); if let Some(item) = q.next() { let media_id = item.media_id.clone(); drop(q); let client = client.read().clone(); spawn(async move { if let Ok(media) = client.get_media(&media_id).await { selected_media.set(Some(media)); auto_play_media.set(true); } }); } } }, on_add_to_queue: { move |item: crate::components::media_player::QueueItem| { play_queue.write().add(item); show_toast("Added to queue".into(), false); } }, on_navigate_to_media: { let client = client.read().clone(); move |media_id: String| { let client = client.clone(); spawn(async move { match client.get_media(&media_id).await { Ok(media) => { // Load tags for the new media if let Ok(mtags) = client.get_media_tags(&media_id).await { media_tags.set(mtags); } selected_media.set(Some(media)); auto_play_media.set(false); } Err(e) => show_toast(format!("Failed to load linked note: {e}"), true), } }); } }, } }, None => rsx! { div { class: "empty-state", h3 { class: "empty-title", "No media selected" } } }, } } View::Tags => rsx! { tags::Tags { tags: tags_list.read().clone(), on_create: { let client = client.read().clone(); let refresh_tags = refresh_tags.clone(); move |(name, parent_id): (String, Option)| { let client = client.clone(); let refresh_tags = refresh_tags.clone(); spawn(async move { match client.create_tag(&name, parent_id.as_deref()).await { Ok(_) => { show_toast("Tag created".into(), false); refresh_tags(); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_delete: { let client = client.read().clone(); let refresh_tags = refresh_tags.clone(); move |id: String| { let client = client.clone(); let refresh_tags = refresh_tags.clone(); spawn(async move { match client.delete_tag(&id).await { Ok(_) => { show_toast("Tag deleted".into(), false); refresh_tags(); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, } }, View::Collections => rsx! { collections::Collections { collections: collections_list.read().clone(), collection_members: collection_members.read().clone(), viewing_collection: viewing_collection.read().clone(), all_media: media_list.read().clone(), on_create: { let client = client.read().clone(); let refresh_collections = refresh_collections.clone(); move | (name, kind, desc, filter): (String, String, Option, Option)| { let client = client.clone(); let refresh_collections = refresh_collections.clone(); spawn(async move { match client .create_collection(&name, &kind, desc.as_deref(), filter.as_deref()) .await { Ok(_) => { show_toast("Collection created".into(), false); refresh_collections(); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_delete: { let client = client.read().clone(); let refresh_collections = refresh_collections.clone(); move |id: String| { let client = client.clone(); let refresh_collections = refresh_collections.clone(); spawn(async move { match client.delete_collection(&id).await { Ok(_) => { show_toast("Collection deleted".into(), false); refresh_collections(); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_view_members: { let client = client.read().clone(); move |col_id: String| { let client = client.clone(); let col_id2 = col_id.clone(); spawn(async move { match client.get_collection_members(&col_id2).await { Ok(members) => { collection_members.set(members); viewing_collection.set(Some(col_id2)); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_back_to_list: move |_| { viewing_collection.set(None); collection_members.set(Vec::new()); }, on_remove_member: { let client = client.read().clone(); move |(col_id, media_id): (String, String)| { let client = client.clone(); let col_id2 = col_id.clone(); spawn(async move { match client.remove_from_collection(&col_id, &media_id).await { Ok(_) => { show_toast("Removed from collection".into(), false); if let Ok(members) = client .get_collection_members(&col_id2) .await { collection_members.set(members); } } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_select: { let client = client.read().clone(); move |id: String| { let client = client.clone(); spawn(async move { if let Ok(item) = client.get_media(&id).await { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); selected_media.set(Some(item)); current_view.set(View::Detail); } }); } }, on_add_member: { let client = client.read().clone(); move |(col_id, media_id): (String, String)| { let client = client.clone(); let col_id2 = col_id.clone(); spawn(async move { match client.add_to_collection(&col_id, &media_id, 0).await { Ok(_) => { show_toast("Added to collection".into(), false); if let Ok(members) = client .get_collection_members(&col_id2) .await { collection_members.set(members); } } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, } }, View::Audit => { let page_size = *audit_page_size.read(); let total = *audit_total_count.read(); let total_pages = if page_size > 0 { total.div_ceil(page_size) } else { 1 }; rsx! { audit::AuditLog { entries: audit_list.read().clone(), audit_page: *audit_page.read(), total_pages, audit_filter: audit_filter.read().clone(), on_select: { let client = client.read().clone(); move |id: String| { let client = client.clone(); spawn(async move { if let Ok(item) = client.get_media(&id).await { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); selected_media.set(Some(item)); current_view.set(View::Detail); } }); } }, on_page_change: { let refresh_audit = refresh_audit.clone(); move |page: u64| { audit_page.set(page); refresh_audit(); } }, on_filter_change: { let refresh_audit = refresh_audit.clone(); move |filter: String| { audit_filter.set(filter); audit_page.set(0); refresh_audit(); } }, } } } View::Import => rsx! { import::Import { tags: tags_list.read().clone(), collections: collections_list.read().clone(), scan_progress: scan_progress.read().clone(), is_importing: *import_in_progress.read(), on_import_file: { let client = client.read().clone(); 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 // 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 if *import_in_progress.read() { 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 { Ok(resp) => { if resp.was_duplicate { show_toast( "Duplicate file (already imported)".into(), false, ); } else { show_toast(format!("Imported: {}", resp.media_id), false); } refresh_media(); } Err(e) => show_toast(format!("Import failed: {e}"), true), } } else { match client .import_with_options( &path, &tag_ids, &new_tags, col_id.as_deref(), ) .await { Ok(resp) => { if resp.was_duplicate { show_toast( "Duplicate file (already imported)".into(), false, ); } else { show_toast( format!("Imported with tags/collection: {}", resp.media_id), false, ); } refresh_media(); if !new_tags.is_empty() { refresh_tags(); } } Err(e) => show_toast(format!("Import failed: {e}"), true), } } import_progress.set((1, 1)); import_current_file.set(None); import_in_progress.set(false); }); } }, on_import_directory: { let client = client.read().clone(); 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 = 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); 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())); } 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)); } 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) => { loop { match client.scan_status().await { Ok(status) => { let done = !status.scanning; import_progress .set((status.files_processed, status.files_found)); 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; show_toast( format!("Scan complete: {total} files processed"), false, ); break; } } Err(_) => break, } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } refresh_media(); } Err(e) => show_toast(format!("Scan failed: {e}"), true), } import_current_file.set(None); import_progress.set((0, 0)); import_in_progress.set(false); }); } }, on_import_batch: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); let refresh_tags = refresh_tags.clone(); move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| { 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 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 { 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); }); } }, on_preview_directory: { let client = client.read().clone(); move |(path, recursive): (String, bool)| { let client = client.clone(); spawn(async move { match client.preview_directory(&path, recursive).await { Ok(resp) => { preview_total_size.set(resp.total_size); preview_files.set(resp.files); } Err(e) => { show_toast(format!("Preview failed: {e}"), true); preview_files.set(Vec::new()); preview_total_size.set(0); } } }); } }, 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 => { let refresh_db_stats = { let client = client.read().clone(); move || { let client = client.clone(); spawn(async move { match client.database_stats().await { Ok(stats) => db_stats.set(Some(stats)), Err(e) => { show_toast(format!("Failed to load stats: {e}"), true) } } }); } }; rsx! { database::Database { stats: db_stats.read().clone(), on_refresh: { let refresh_db_stats = refresh_db_stats.clone(); move |_| refresh_db_stats() }, on_vacuum: { let client = client.read().clone(); let refresh_db_stats = refresh_db_stats.clone(); move |_| { let client = client.clone(); let refresh_db_stats = refresh_db_stats.clone(); spawn(async move { show_toast("Vacuuming database...".into(), false); match client.vacuum_database().await { Ok(()) => { show_toast("Vacuum complete".into(), false); refresh_db_stats(); } Err(e) => show_toast(format!("Vacuum failed: {e}"), true), } }); } }, on_clear: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); let refresh_tags = refresh_tags.clone(); let refresh_collections = refresh_collections.clone(); let refresh_db_stats = refresh_db_stats.clone(); move |_| { let client = client.clone(); let refresh_media = refresh_media.clone(); let refresh_tags = refresh_tags.clone(); let refresh_collections = refresh_collections.clone(); let refresh_db_stats = refresh_db_stats.clone(); spawn(async move { match client.clear_database().await { Ok(()) => { show_toast("All data cleared".into(), false); refresh_media(); refresh_tags(); refresh_collections(); refresh_db_stats(); } Err(e) => show_toast(format!("Clear failed: {e}"), true), } }); } }, on_backup: { move |_path: String| { show_toast("Backup not yet implemented on server".into(), false); } }, } } } View::Duplicates => { rsx! { duplicates::Duplicates { groups: duplicate_groups.read().clone(), server_url: server_url.read().clone(), on_delete: { let client = client.read().clone(); move |media_id: String| { let client = client.clone(); spawn(async move { match client.delete_media(&media_id).await { Ok(_) => { show_toast("Deleted duplicate".into(), false); if let Ok(groups) = client.list_duplicates().await { duplicate_groups.set(groups); } } Err(e) => show_toast(format!("Delete failed: {e}"), true), } }); } }, on_refresh: { let client = client.read().clone(); move |_| { let client = client.clone(); spawn(async move { match client.list_duplicates().await { Ok(groups) => duplicate_groups.set(groups), Err(e) => show_toast(format!("Failed to load duplicates: {e}"), true), } }); } }, } } } View::Statistics => { let refresh_stats = { let client = client.read().clone(); move || { let client = client.clone(); spawn(async move { match client.library_statistics().await { Ok(stats) => library_stats.set(Some(stats)), Err(e) => show_toast(format!("Failed to load statistics: {e}"), true), } }); } }; rsx! { statistics::Statistics { stats: library_stats.read().clone(), on_refresh: { let refresh_stats = refresh_stats.clone(); move |_| refresh_stats() }, } } } View::Tasks => { let refresh_tasks = { let client = client.read().clone(); move || { let client = client.clone(); spawn(async move { match client.list_scheduled_tasks().await { Ok(tasks_data) => scheduled_tasks.set(tasks_data), Err(e) => show_toast(format!("Failed to load tasks: {e}"), true), } }); } }; rsx! { tasks::Tasks { tasks: scheduled_tasks.read().clone(), on_refresh: { let refresh_tasks = refresh_tasks.clone(); move |_| refresh_tasks() }, on_toggle: { let client = client.read().clone(); let refresh_tasks = refresh_tasks.clone(); move |task_id: String| { let client = client.clone(); let refresh_tasks = refresh_tasks.clone(); spawn(async move { match client.toggle_scheduled_task(&task_id).await { Ok(_) => { show_toast("Task toggled".into(), false); refresh_tasks(); } Err(e) => show_toast(format!("Toggle failed: {e}"), true), } }); } }, on_run_now: { let client = client.read().clone(); let refresh_tasks = refresh_tasks.clone(); move |task_id: String| { let client = client.clone(); let refresh_tasks = refresh_tasks.clone(); spawn(async move { match client.run_scheduled_task_now(&task_id).await { Ok(_) => { show_toast("Task started".into(), false); refresh_tasks(); } Err(e) => show_toast(format!("Run failed: {e}"), true), } }); } }, } } } View::Settings => { let cfg_ref = config_data.read(); match cfg_ref.as_ref() { Some(cfg) => rsx! { settings::Settings { config: cfg.clone(), on_add_root: { let client = client.read().clone(); move |path: String| { let client = client.clone(); spawn(async move { match client.add_root(&path).await { Ok(new_cfg) => { config_data.set(Some(new_cfg)); show_toast("Root added".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_remove_root: { let client = client.read().clone(); move |path: String| { let client = client.clone(); spawn(async move { match client.remove_root(&path).await { Ok(new_cfg) => { config_data.set(Some(new_cfg)); show_toast("Root removed".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_toggle_watch: { let client = client.read().clone(); move |enabled: bool| { let client = client.clone(); spawn(async move { match client.update_scanning(Some(enabled), None, None).await { Ok(new_cfg) => { config_data.set(Some(new_cfg)); let state = if enabled { "enabled" } else { "disabled" }; show_toast(format!("Watching {state}"), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_update_poll_interval: { let client = client.read().clone(); move |secs: u64| { let client = client.clone(); spawn(async move { match client.update_scanning(None, Some(secs), None).await { Ok(new_cfg) => { config_data.set(Some(new_cfg)); show_toast(format!("Poll interval set to {secs}s"), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_update_ignore_patterns: { let client = client.read().clone(); move |patterns: Vec| { let client = client.clone(); spawn(async move { match client.update_scanning(None, None, Some(patterns)).await { Ok(new_cfg) => { config_data.set(Some(new_cfg)); show_toast("Ignore patterns updated".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, on_update_ui_config: { let client = client.read().clone(); move |updates: serde_json::Value| { let client = client.clone(); spawn(async move { match client.update_ui_config(updates).await { 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)); } show_toast("UI preferences updated".into(), false); } Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, } }, None => rsx! { div { class: "empty-state", h3 { class: "empty-title", "Loading settings..." } } }, } } View::Graph => { rsx! { graph_view::GraphView { client: client.read().clone(), center_id: None, on_navigate: { let client = client.read().clone(); move |media_id: String| { let client = client.clone(); spawn(async move { match client.get_media(&media_id).await { Ok(media) => { // Load tags for the media if let Ok(mtags) = client.get_media_tags(&media_id).await { media_tags.set(mtags); } selected_media.set(Some(media)); current_view.set(View::Detail); } Err(e) => show_toast(format!("Failed to load: {e}"), true), } }); } }, } } } } } } } // Phase 7.1: Help overlay if *show_help.read() { div { class: "help-overlay", onclick: move |_| show_help.set(false), div { class: "help-dialog", 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" } } div { class: "shortcut-row", kbd { "/" } span { "Focus search" } } div { class: "shortcut-row", kbd { "Ctrl+K" } 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", onclick: move |_| show_help.set(false), "Close" } } } } } } // end else (auth not required) // Phase 1.4: Toast queue - show up to 3 stacked from bottom div { class: "toast-container", { let toasts = toast_queue.read().clone(); let visible: Vec<_> = toasts.iter().rev().take(3).rev().cloned().collect(); rsx! { for (msg , is_error , id) in visible { div { key: "{id}", class: if is_error { "toast error" } else { "toast success" }, "{msg}" } } } } } } }