pinakes/crates/pinakes-ui/src/app.rs
NotAShelf 3ccddce7fd
treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
2026-03-06 18:29:33 +03:00

2536 lines
150 KiB
Rust

use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use dioxus::prelude::*;
use dioxus_free_icons::{
Icon,
IconShape,
icons::fa_solid_icons::{
FaArrowRightFromBracket,
FaBook,
FaChartBar,
FaChevronLeft,
FaChevronRight,
FaClock,
FaClockRotateLeft,
FaCopy,
FaDatabase,
FaDiagramProject,
FaDownload,
FaGear,
FaLayerGroup,
FaMagnifyingGlass,
FaTags,
},
};
use futures::future::join_all;
#[component]
fn NavIcon<T: IconShape + Clone + PartialEq + 'static>(icon: T) -> Element {
rsx! {
Icon {
width: 16,
height: 16,
fill: "currentColor",
icon,
}
}
}
use crate::{
client::*,
components::{
audit,
collections,
database,
detail,
duplicates,
graph_view,
import,
library,
media_player::PlayQueue,
search,
settings,
statistics,
tags,
tasks,
},
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::<MediaResponse>::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::<MediaResponse>::new);
let mut search_total = use_signal(|| 0u64);
let mut selected_media = use_signal(|| Option::<MediaResponse>::None);
let mut media_tags = use_signal(Vec::<TagResponse>::new);
let mut tags_list = use_signal(Vec::<TagResponse>::new);
let mut collections_list = use_signal(Vec::<CollectionResponse>::new);
let mut audit_list = use_signal(Vec::<AuditEntryResponse>::new);
let mut config_data = use_signal(|| Option::<ConfigResponse>::None);
let mut db_stats =
use_signal(|| Option::<crate::client::DatabaseStatsResponse>::None);
let mut library_stats =
use_signal(|| Option::<crate::client::LibraryStatisticsResponse>::None);
let mut scheduled_tasks =
use_signal(Vec::<crate::client::ScheduledTaskResponse>::new);
let mut duplicate_groups =
use_signal(Vec::<crate::client::DuplicateGroupResponse>::new);
let mut preview_files = use_signal(Vec::<DirectoryPreviewFile>::new);
let mut preview_total_size = use_signal(|| 0u64);
let mut viewing_collection = use_signal(|| Option::<String>::None);
let mut collection_members = use_signal(Vec::<MediaResponse>::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::<String>::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::<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);
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::<ScanStatusResponse>::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::<UserInfoResponse>::None);
let mut login_error = use_signal(|| Option::<String>::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::<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();
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! {
document::Stylesheet { href: styles::STYLES }
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",
NavIcon { icon: FaBook }
}
span { class: "nav-item-text", "Library" }
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",
NavIcon { icon: FaMagnifyingGlass }
}
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",
NavIcon { icon: FaDownload }
}
span { class: "nav-item-text", "Import" }
}
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",
NavIcon { icon: FaDiagramProject }
}
span { class: "nav-item-text", "Graph" }
}
}
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",
NavIcon { icon: FaTags }
}
span { class: "nav-item-text", "Tags" }
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",
NavIcon { icon: FaLayerGroup }
}
span { class: "nav-item-text", "Collections" }
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",
NavIcon { icon: FaClockRotateLeft }
}
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",
NavIcon { icon: FaCopy }
}
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",
NavIcon { icon: FaChartBar }
}
span { class: "nav-item-text", "Statistics" }
}
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",
NavIcon { icon: FaClock }
}
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",
NavIcon { icon: FaGear }
}
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",
NavIcon { icon: FaDatabase }
}
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() {
NavIcon { icon: FaChevronRight }
} else {
NavIcon { icon: FaChevronLeft }
}
}
// 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);
});
}
},
NavIcon { icon: FaArrowRightFromBracket }
"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<String>| {
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<String>, Vec<String>)| {
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>, 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<Vec<String>>| {
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<String> = 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<String>)| {
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<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 => {
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);
}
// Extract file name from path
// 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
// Load tags for the media
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<String>)| {
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<String>, Option<String>)|
{
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| {
let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
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<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);
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<String>| {
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) => {
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}" }
}
}
}
}
}
}