Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
2451 lines
153 KiB
Rust
2451 lines
153 KiB
Rust
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::<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! {
|
|
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<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);
|
|
}
|
|
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| {
|
|
// 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<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) => {
|
|
// 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}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|