diff --git a/crates/pinakes-core/src/plugin/loader.rs b/crates/pinakes-core/src/plugin/loader.rs index 4201d10..0c80f18 100644 --- a/crates/pinakes-core/src/plugin/loader.rs +++ b/crates/pinakes-core/src/plugin/loader.rs @@ -165,13 +165,14 @@ impl PluginLoader { // Check content-length header before downloading const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB if let Some(content_length) = response.content_length() - && content_length > MAX_PLUGIN_SIZE { - return Err(anyhow!( - "Plugin archive too large: {} bytes (max {} bytes)", - content_length, - MAX_PLUGIN_SIZE - )); - } + && content_length > MAX_PLUGIN_SIZE + { + return Err(anyhow!( + "Plugin archive too large: {} bytes (max {} bytes)", + content_length, + MAX_PLUGIN_SIZE + )); + } let bytes = response .bytes() diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index 2a04cf6..41f6382 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -106,26 +106,26 @@ impl WasmPlugin { // If there are params and memory is available, write them let mut alloc_offset: i32 = 0; if !params.is_empty() - && let Some(mem) = &memory { - // Call the plugin's alloc function if available, otherwise write at offset 0 - let offset = if let Ok(alloc) = - instance.get_typed_func::(&mut store, "alloc") - { - let result = alloc.call_async(&mut store, params.len() as i32).await?; - if result < 0 { - return Err(anyhow!("plugin alloc returned negative offset: {}", result)); - } - result as usize - } else { - 0 - }; - - alloc_offset = offset as i32; - let mem_data = mem.data_mut(&mut store); - if offset + params.len() <= mem_data.len() { - mem_data[offset..offset + params.len()].copy_from_slice(params); + && let Some(mem) = &memory + { + // Call the plugin's alloc function if available, otherwise write at offset 0 + let offset = if let Ok(alloc) = instance.get_typed_func::(&mut store, "alloc") + { + let result = alloc.call_async(&mut store, params.len() as i32).await?; + if result < 0 { + return Err(anyhow!("plugin alloc returned negative offset: {}", result)); } + result as usize + } else { + 0 + }; + + alloc_offset = offset as i32; + let mem_data = mem.data_mut(&mut store); + if offset + params.len() <= mem_data.len() { + mem_data[offset..offset + params.len()].copy_from_slice(params); } + } // Look up the exported function and call it let func = instance @@ -209,14 +209,15 @@ impl HostFunctions { let start = ptr as usize; let end = start + len as usize; if end <= data.len() - && let Ok(msg) = std::str::from_utf8(&data[start..end]) { - match level { - 0 => tracing::error!(plugin = true, "{}", msg), - 1 => tracing::warn!(plugin = true, "{}", msg), - 2 => tracing::info!(plugin = true, "{}", msg), - _ => tracing::debug!(plugin = true, "{}", msg), - } + && let Ok(msg) = std::str::from_utf8(&data[start..end]) + { + match level { + 0 => tracing::error!(plugin = true, "{}", msg), + 1 => tracing::warn!(plugin = true, "{}", msg), + 2 => tracing::info!(plugin = true, "{}", msg), + _ => tracing::debug!(plugin = true, "{}", msg), } + } } }, )?; @@ -258,11 +259,7 @@ impl HostFunctions { .filesystem .read .iter() - .any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| path.starts_with(a)) - }); + .any(|allowed| allowed.canonicalize().is_ok_and(|a| path.starts_with(a))); if !can_read { tracing::warn!(path = %path_str, "plugin read access denied"); diff --git a/crates/pinakes-core/src/transcode.rs b/crates/pinakes-core/src/transcode.rs index 0c3a611..f41baaa 100644 --- a/crates/pinakes-core/src/transcode.rs +++ b/crates/pinakes-core/src/transcode.rs @@ -293,9 +293,10 @@ impl TranscodeService { // Clean up cache directory if let Some(path) = cache_path - && let Err(e) = tokio::fs::remove_dir_all(&path).await { - tracing::error!("failed to remove transcode cache directory: {}", e); - } + && let Err(e) = tokio::fs::remove_dir_all(&path).await + { + tracing::error!("failed to remove transcode cache directory: {}", e); + } Ok(()) } @@ -311,9 +312,10 @@ impl TranscodeService { .iter() .filter_map(|(id, sess)| { if let Some(expires) = sess.expires_at - && now > expires { - return Some((*id, sess.cache_path.clone())); - } + && now > expires + { + return Some((*id, sess.cache_path.clone())); + } None }) .collect(); @@ -475,21 +477,22 @@ async fn run_ffmpeg( while let Ok(Some(line)) = lines.next_line().await { // FFmpeg progress output: "out_time_us=12345678" if let Some(time_str) = line.strip_prefix("out_time_us=") - && let Ok(us) = time_str.trim().parse::() { - let secs = us / 1_000_000.0; - // Calculate progress based on known duration - let progress = match duration_secs { - Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32, - _ => { - // Duration unknown; don't update progress - continue; - } - }; - let mut s = sessions.write().await; - if let Some(sess) = s.get_mut(&session_id) { - sess.progress = progress; + && let Ok(us) = time_str.trim().parse::() + { + let secs = us / 1_000_000.0; + // Calculate progress based on known duration + let progress = match duration_secs { + Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32, + _ => { + // Duration unknown; don't update progress + continue; } + }; + let mut s = sessions.write().await; + if let Some(sess) = s.get_mut(&session_id) { + sess.progress = progress; } + } } })) } else { diff --git a/crates/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs index efef325..e539053 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -100,13 +100,14 @@ pub async fn update_playlist( Json(req): Json, ) -> Result, ApiError> { if let Some(ref name) = req.name - && (name.is_empty() || name.chars().count() > 255) { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "playlist name must be 1-255 characters".into(), - ), - )); - } + && (name.is_empty() || name.chars().count() > 255) + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "playlist name must be 1-255 characters".into(), + ), + )); + } let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, true).await?; let playlist = state diff --git a/crates/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs index 0c0a31d..2904e0e 100644 --- a/crates/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -137,14 +137,15 @@ pub async fn create_share_link( }; const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year if let Some(h) = req.expires_in_hours - && h > MAX_EXPIRY_HOURS { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation(format!( - "expires_in_hours cannot exceed {}", - MAX_EXPIRY_HOURS - )), - )); - } + && h > MAX_EXPIRY_HOURS + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation(format!( + "expires_in_hours cannot exceed {}", + MAX_EXPIRY_HOURS + )), + )); + } let expires_at = req .expires_in_hours .map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64)); @@ -169,13 +170,12 @@ pub async fn access_shared_media( let link = state.storage.get_share_link(&token).await?; // Check expiration if let Some(expires) = link.expires_at - && chrono::Utc::now() > expires { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "share link has expired".into(), - ), - )); - } + && chrono::Utc::now() > expires + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation("share link has expired".into()), + )); + } // Verify password if set if let Some(ref hash) = link.password_hash { let password = match query.password.as_deref() { diff --git a/crates/pinakes-server/src/routes/streaming.rs b/crates/pinakes-server/src/routes/streaming.rs index 0ab5fb8..3013a9b 100644 --- a/crates/pinakes-server/src/routes/streaming.rs +++ b/crates/pinakes-server/src/routes/streaming.rs @@ -104,30 +104,31 @@ pub async fn hls_segment( // Look for an active/completed transcode session if let Some(transcode_service) = &state.transcode_service - && let Some(session) = transcode_service.find_session(media_id, &profile).await { - let segment_path = session.cache_path.join(&segment); + && let Some(session) = transcode_service.find_session(media_id, &profile).await + { + let segment_path = session.cache_path.join(&segment); - if segment_path.exists() { - let data = tokio::fs::read(&segment_path).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to read segment: {}", e), - )) - })?; + if segment_path.exists() { + let data = tokio::fs::read(&segment_path).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to read segment: {}", e), + )) + })?; - return Ok(axum::response::Response::builder() - .header("Content-Type", "video/MP2T") - .body(axum::body::Body::from(data)) - .unwrap()); - } - - // Session exists but segment not ready yet return Ok(axum::response::Response::builder() - .status(StatusCode::ACCEPTED) - .header("Retry-After", "2") - .body(axum::body::Body::from("segment not yet available")) + .header("Content-Type", "video/MP2T") + .body(axum::body::Body::from(data)) .unwrap()); } + // Session exists but segment not ready yet + return Ok(axum::response::Response::builder() + .status(StatusCode::ACCEPTED) + .header("Retry-After", "2") + .body(axum::body::Body::from("segment not yet available")) + .unwrap()); + } + Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST /media/{id}/transcode" @@ -206,29 +207,30 @@ pub async fn dash_segment( let media_id = MediaId(id); if let Some(transcode_service) = &state.transcode_service - && let Some(session) = transcode_service.find_session(media_id, &profile).await { - let segment_path = session.cache_path.join(&segment); + && let Some(session) = transcode_service.find_session(media_id, &profile).await + { + let segment_path = session.cache_path.join(&segment); - if segment_path.exists() { - let data = tokio::fs::read(&segment_path).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to read segment: {}", e), - )) - })?; - - return Ok(axum::response::Response::builder() - .header("Content-Type", "video/mp4") - .body(axum::body::Body::from(data)) - .unwrap()); - } + if segment_path.exists() { + let data = tokio::fs::read(&segment_path).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to read segment: {}", e), + )) + })?; return Ok(axum::response::Response::builder() - .status(StatusCode::ACCEPTED) - .header("Retry-After", "2") - .body(axum::body::Body::from("segment not yet available")) + .header("Content-Type", "video/mp4") + .body(axum::body::Body::from(data)) .unwrap()); } + return Ok(axum::response::Response::builder() + .status(StatusCode::ACCEPTED) + .header("Retry-After", "2") + .body(axum::body::Body::from("segment not yet available")) + .unwrap()); + } + Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST /media/{id}/transcode" diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index 4f4fb2e..3f12f85 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -1028,18 +1028,19 @@ async fn handle_action( } Action::Edit => { if state.current_view == View::Detail - && let Some(ref media) = state.selected_media { - // Populate edit fields from selected media - state.edit_title = media.title.clone().unwrap_or_default(); - state.edit_artist = media.artist.clone().unwrap_or_default(); - state.edit_album = media.album.clone().unwrap_or_default(); - state.edit_genre = media.genre.clone().unwrap_or_default(); - state.edit_year = media.year.map(|y| y.to_string()).unwrap_or_default(); - state.edit_description = media.description.clone().unwrap_or_default(); - state.edit_field_index = Some(0); - state.input_mode = true; - state.current_view = View::MetadataEdit; - } + && let Some(ref media) = state.selected_media + { + // Populate edit fields from selected media + state.edit_title = media.title.clone().unwrap_or_default(); + state.edit_artist = media.artist.clone().unwrap_or_default(); + state.edit_album = media.album.clone().unwrap_or_default(); + state.edit_genre = media.genre.clone().unwrap_or_default(); + state.edit_year = media.year.map(|y| y.to_string()).unwrap_or_default(); + state.edit_description = media.description.clone().unwrap_or_default(); + state.edit_field_index = Some(0); + state.input_mode = true; + state.current_view = View::MetadataEdit; + } } Action::Vacuum => { if state.current_view == View::Database { @@ -1069,90 +1070,91 @@ async fn handle_action( Action::Toggle => { if state.current_view == View::Tasks && let Some(idx) = state.scheduled_tasks_selected - && let Some(task) = state.scheduled_tasks.get(idx) { - let task_id = task.id.clone(); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.toggle_scheduled_task(&task_id).await { - Ok(()) => { - // Refresh tasks list - if let Ok(tasks) = client.list_scheduled_tasks().await { - let _ = tx.send(AppEvent::ApiResult( - ApiResult::ScheduledTasks(tasks), - )); - } - } - Err(e) => { - tracing::error!("Failed to toggle task: {}", e); - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( - format!("Toggle task failed: {e}"), - ))); - } + && let Some(task) = state.scheduled_tasks.get(idx) + { + let task_id = task.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.toggle_scheduled_task(&task_id).await { + Ok(()) => { + // Refresh tasks list + if let Ok(tasks) = client.list_scheduled_tasks().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); } - }); + } + Err(e) => { + tracing::error!("Failed to toggle task: {}", e); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Toggle task failed: {e}" + )))); + } } + }); + } } Action::RunNow => { if state.current_view == View::Tasks && let Some(idx) = state.scheduled_tasks_selected - && let Some(task) = state.scheduled_tasks.get(idx) { - let task_id = task.id.clone(); - let task_name = task.name.clone(); - state.status_message = Some(format!("Running task: {task_name}...")); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.run_task_now(&task_id).await { - Ok(()) => { - // Refresh tasks list - if let Ok(tasks) = client.list_scheduled_tasks().await { - let _ = tx.send(AppEvent::ApiResult( - ApiResult::ScheduledTasks(tasks), - )); - } - } - Err(e) => { - tracing::error!("Failed to run task: {}", e); - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( - format!("Run task failed: {e}"), - ))); - } + && let Some(task) = state.scheduled_tasks.get(idx) + { + let task_id = task.id.clone(); + let task_name = task.name.clone(); + state.status_message = Some(format!("Running task: {task_name}...")); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.run_task_now(&task_id).await { + Ok(()) => { + // Refresh tasks list + if let Ok(tasks) = client.list_scheduled_tasks().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); } - }); + } + Err(e) => { + tracing::error!("Failed to run task: {}", e); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Run task failed: {e}" + )))); + } } + }); + } } Action::Save => { if state.current_view == View::MetadataEdit - && let Some(ref media) = state.selected_media { - let updates = serde_json::json!({ - "title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) }, - "artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) }, - "album": if state.edit_album.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_album.clone()) }, - "genre": if state.edit_genre.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_genre.clone()) }, - "year": state.edit_year.parse::().ok(), - "description": if state.edit_description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_description.clone()) }, - }); - let media_id = media.id.clone(); - let client = client.clone(); - let tx = event_sender.clone(); - state.status_message = Some("Saving...".to_string()); - tokio::spawn(async move { - match client.update_media(&media_id, updates).await { - Ok(_) => { - let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaUpdated)); - } - Err(e) => { - tracing::error!("Failed to update media: {}", e); - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Update failed: {e}" - )))); - } + && let Some(ref media) = state.selected_media + { + let updates = serde_json::json!({ + "title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) }, + "artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) }, + "album": if state.edit_album.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_album.clone()) }, + "genre": if state.edit_genre.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_genre.clone()) }, + "year": state.edit_year.parse::().ok(), + "description": if state.edit_description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_description.clone()) }, + }); + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + state.status_message = Some("Saving...".to_string()); + tokio::spawn(async move { + match client.update_media(&media_id, updates).await { + Ok(_) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaUpdated)); } - }); - state.input_mode = false; - state.current_view = View::Detail; - } + Err(e) => { + tracing::error!("Failed to update media: {}", e); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Update failed: {e}" + )))); + } + } + }); + state.input_mode = false; + state.current_view = View::Detail; + } } Action::NavigateLeft | Action::NavigateRight | Action::None => {} } diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index f85a48f..dad285c 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -309,650 +309,340 @@ pub fn App() -> Element { } } else { // Phase 7.1: Keyboard shortcuts - div { class: "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); - match key { - Key::Escape => { - if *show_help.read() { - show_help.set(false); - } else if *current_view.read() == View::Detail { - current_view.set(View::Library); + div { + class: "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); + match key { + Key::Escape => { + if *show_help.read() { + show_help.set(false); + } else if *current_view.read() == View::Detail { + current_view.set(View::Library); + } } + 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); + } + Key::Character(ref c) if c == "?" && !ctrl && !meta => { + show_help.toggle(); + } + _ => {} } - 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); - } - Key::Character(ref c) if c == "?" && !ctrl && !meta => { - show_help.toggle(); - } - _ => {} } - } - }, + }, - // Sidebar - div { class: if *sidebar_collapsed.read() { "sidebar collapsed" } else { "sidebar" }, - div { class: "sidebar-header", - span { class: "logo", "Pinakes" } - span { class: "version", "v0.1" } - } + // 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::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" } - - // 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}" } + div { class: "nav-section", + div { class: "nav-label", "Media" } button { - class: "btn btn-ghost btn-sm", + 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 { - let _ = client.logout().await; - current_user.set(None); - auth_required.set(true); + if let Ok(groups) = client.list_duplicates().await { + duplicate_groups.set(groups); + } }); } }, - "Logout" + span { class: "nav-icon", "\u{2261}" } + span { class: "nav-item-text", "Duplicates" } + } + 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" } + + // 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}" } + } + } } } } - // Server status indicator - div { class: "sidebar-footer", - div { class: "status-indicator", + // 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..." + } + } + { - 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..." - } - } - - {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), - } - }); + // 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" } + } } - }, - on_delete: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |id: String| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.delete_media(&id).await { - Ok(_) => { - show_toast("Media deleted".into(), false); - refresh_media(); - } - Err(e) => show_toast(format!("Delete failed: {e}"), true), - } - }); - } - }, - on_batch_delete: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |ids: Vec| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.batch_delete(&ids).await { - Ok(resp) => { - show_toast(format!("Deleted {} items", resp.processed), false); - refresh_media(); - } - Err(e) => show_toast(format!("Batch delete failed: {e}"), true), - } - }); - } - }, - on_batch_tag: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |(ids, tag_ids): (Vec, Vec)| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.batch_tag(&ids, &tag_ids).await { - Ok(resp) => { - show_toast(format!("Tagged {} items", resp.processed), false); - refresh_media(); - } - Err(e) => show_toast(format!("Batch tag failed: {e}"), true), - } - }); - } - }, - on_batch_collection: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |(ids, col_id): (Vec, String)| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.batch_add_to_collection(&ids, &col_id).await { - Ok(resp) => { - show_toast(format!("Added {} items to collection", resp.processed), false); - refresh_media(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_page_change: { - let client = client.read().clone(); - move |page: u64| { - media_page.set(page); - let client = client.clone(); - spawn(async move { - loading.set(true); - let offset = page * *media_page_size.read(); - let limit = *media_page_size.read(); - let sort = media_sort.read().clone(); - if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { - media_list.set(items); - } - loading.set(false); - }); - } - }, - on_page_size_change: { - let client = client.read().clone(); - move |size: u64| { - media_page_size.set(size); - media_page.set(0); - let client = client.clone(); - spawn(async move { - loading.set(true); - let sort = media_sort.read().clone(); - if let Ok(items) = client.list_media(0, size, Some(&sort)).await { - media_list.set(items); - } - loading.set(false); - }); - } - }, - // Phase 2.2: Sort wiring - actually refetch with sort - on_sort_change: { - let client = client.read().clone(); - move |sort: String| { - media_sort.set(sort.clone()); - media_page.set(0); - let client = client.clone(); - spawn(async move { - loading.set(true); - let limit = *media_page_size.read(); - if let Ok(items) = client.list_media(0, limit, Some(&sort)).await { - media_list.set(items); - } - loading.set(false); - }); - } - }, - on_select_all_global: { - let client = client.read().clone(); - move |callback: EventHandler>| { - let client = client.clone(); - spawn(async move { - let total = *media_total_count.read(); - let sort = media_sort.read().clone(); - match client.list_media(0, total, Some(&sort)).await { - Ok(items) => { - let all_ids: Vec = items.iter().map(|m| m.id.clone()).collect(); - callback.call(all_ids); - } - Err(e) => show_toast(format!("Failed to select all: {e}"), true), - } - }); - } - }, - on_delete_all: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |_: ()| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.delete_all_media().await { - Ok(resp) => { - show_toast(format!("Deleted {} items", resp.processed), false); - refresh_media(); - } - Err(e) => show_toast(format!("Delete all failed: {e}"), true), - } - }); - } - }, - } - }, - // Phase 4.1 + 4.2: Search improvements - View::Search => rsx! { - search::Search { - results: search_results.read().clone(), - total_count: *search_total.read(), - search_page: *search_page.read(), - page_size: *search_page_size.read(), - server_url: server_url.read().clone(), - on_search: { - let client = client.read().clone(); - move |(q, sort): (String, Option)| { - let client = client.clone(); - search_page.set(0); - last_search_query.set(q.clone()); - last_search_sort.set(sort.clone()); - spawn(async move { - loading.set(true); - let offset = 0; - let limit = *search_page_size.read(); - match client.search(&q, sort.as_deref(), offset, limit).await { - Ok(resp) => { - search_total.set(resp.total_count); - search_results.set(resp.items); - } - Err(e) => show_toast(format!("Search failed: {e}"), true), - } - loading.set(false); - }); - } - }, - on_select: { - let client = client.read().clone(); - move |id: String| { - let client = client.clone(); - spawn(async move { - if let Ok(item) = client.get_media(&id).await { - let mtags = client.get_media_tags(&id).await.unwrap_or_default(); - media_tags.set(mtags); - selected_media.set(Some(item)); - current_view.set(View::Detail); - } - }); - } - }, - on_page_change: { - let client = client.read().clone(); - move |page: u64| { - search_page.set(page); - let client = client.clone(); - spawn(async move { - loading.set(true); - let offset = page * *search_page_size.read(); - let limit = *search_page_size.read(); - let q = last_search_query.read().clone(); - let sort = last_search_sort.read().clone(); - match client.search(&q, sort.as_deref(), offset, limit).await { - Ok(resp) => { - search_total.set(resp.total_count); - search_results.set(resp.items); - } - Err(e) => show_toast(format!("Search failed: {e}"), true), - } - loading.set(false); - }); - } - }, - } - }, - // Phase 3.1 + 3.2: Detail view enhancements - 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(), + 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(), - autoplay: *auto_play_media.read(), - on_back: move |_| current_view.set(View::Library), - on_open: { + on_select: { 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); + 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!("Update failed: {e}"), true), + Err(e) => show_toast(format!("Failed to load: {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), - } - }); - } - }, - // Phase 3.2: Delete from detail navigates back and refreshes on_delete: { let client = client.read().clone(); let refresh_media = refresh_media.clone(); @@ -963,8 +653,6 @@ pub fn App() -> Element { 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), @@ -972,640 +660,1026 @@ pub fn App() -> Element { }); } }, - } - }, - None => rsx! { - div { class: "empty-state", - h3 { class: "empty-title", "No media selected" } - } - }, - } - }, - View::Tags => rsx! { - tags::Tags { - tags: tags_list.read().clone(), - on_create: { - let client = client.read().clone(); - let refresh_tags = refresh_tags.clone(); - move |(name, parent_id): (String, Option)| { - let client = client.clone(); - let refresh_tags = refresh_tags.clone(); - spawn(async move { - match client.create_tag(&name, parent_id.as_deref()).await { - Ok(_) => { - show_toast("Tag created".into(), false); - refresh_tags(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - // Phase 5.1: Tags on_delete - confirmation handled inside Tags component - 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), - } - }); - } - }, - } - }, - // Phase 5.2: Collections enhancements - View::Collections => rsx! { - collections::Collections { - collections: collections_list.read().clone(), - collection_members: collection_members.read().clone(), - viewing_collection: viewing_collection.read().clone(), - all_media: media_list.read().clone(), - on_create: { - let client = client.read().clone(); - let refresh_collections = refresh_collections.clone(); - move |(name, kind, desc, filter): (String, String, Option, Option)| { - let client = client.clone(); - let refresh_collections = refresh_collections.clone(); - spawn(async move { - match client.create_collection(&name, &kind, desc.as_deref(), filter.as_deref()).await { - Ok(_) => { - show_toast("Collection created".into(), false); - refresh_collections(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_delete: { - let client = client.read().clone(); - let refresh_collections = refresh_collections.clone(); - move |id: String| { - let client = client.clone(); - let refresh_collections = refresh_collections.clone(); - spawn(async move { - match client.delete_collection(&id).await { - Ok(_) => { - show_toast("Collection deleted".into(), false); - refresh_collections(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_view_members: { - let client = client.read().clone(); - move |col_id: String| { - let client = client.clone(); - let col_id2 = col_id.clone(); - spawn(async move { - match client.get_collection_members(&col_id2).await { - Ok(members) => { - collection_members.set(members); - viewing_collection.set(Some(col_id2)); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_back_to_list: move |_| { - viewing_collection.set(None); - collection_members.set(Vec::new()); - }, - on_remove_member: { - let client = client.read().clone(); - move |(col_id, media_id): (String, String)| { - let client = client.clone(); - let col_id2 = col_id.clone(); - spawn(async move { - match client.remove_from_collection(&col_id, &media_id).await { - Ok(_) => { - show_toast("Removed from collection".into(), false); - if let Ok(members) = client.get_collection_members(&col_id2).await { - collection_members.set(members); + on_batch_delete: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |ids: Vec| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_delete(&ids).await { + Ok(resp) => { + show_toast(format!("Deleted {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Batch delete failed: {e}"), true), } - } - Err(e) => show_toast(format!("Failed: {e}"), true), + }); } - }); - } - }, - // Phase 5.2: Navigate to detail when clicking a collection member - 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); - } - }); - } - }, - // Phase 5.2: Add member to collection - 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); + }, + on_batch_tag: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |(ids, tag_ids): (Vec, Vec)| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_tag(&ids, &tag_ids).await { + Ok(resp) => { + show_toast(format!("Tagged {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Batch tag failed: {e}"), true), } - } - Err(e) => show_toast(format!("Failed: {e}"), true), + }); } - }); + }, + on_batch_collection: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |(ids, col_id): (Vec, String)| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_add_to_collection(&ids, &col_id).await { + Ok(resp) => { + show_toast( + format!("Added {} items to collection", resp.processed), + false, + ); + refresh_media(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_page_change: { + let client = client.read().clone(); + move |page: u64| { + media_page.set(page); + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = page * *media_page_size.read(); + let limit = *media_page_size.read(); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_page_size_change: { + let client = client.read().clone(); + move |size: u64| { + media_page_size.set(size); + media_page.set(0); + let client = client.clone(); + spawn(async move { + loading.set(true); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(0, size, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_sort_change: { + let client = client.read().clone(); + move |sort: String| { + media_sort.set(sort.clone()); + media_page.set(0); + let client = client.clone(); + spawn(async move { + loading.set(true); + let limit = *media_page_size.read(); + if let Ok(items) = client.list_media(0, limit, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_select_all_global: { + let client = client.read().clone(); + move |callback: EventHandler>| { + let client = client.clone(); + spawn(async move { + let total = *media_total_count.read(); + let sort = media_sort.read().clone(); + match client.list_media(0, total, Some(&sort)).await { + Ok(items) => { + let all_ids: Vec = items + .iter() + .map(|m| m.id.clone()) + .collect(); + callback.call(all_ids); + } + Err(e) => show_toast(format!("Failed to select all: {e}"), true), + } + }); + } + }, + on_delete_all: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |_: ()| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.delete_all_media().await { + Ok(resp) => { + show_toast(format!("Deleted {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Delete all failed: {e}"), true), + } + }); + } + }, } }, - } - }, - // Phase 6.1: Audit improvements - 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: 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::Search => rsx! { + search::Search { + results: search_results.read().clone(), + total_count: *search_total.read(), + search_page: *search_page.read(), + page_size: *search_page_size.read(), + server_url: server_url.read().clone(), + on_search: { + let client = client.read().clone(); + move |(q, sort): (String, Option)| { + let client = client.clone(); + search_page.set(0); + last_search_query.set(q.clone()); + last_search_sort.set(sort.clone()); + spawn(async move { + loading.set(true); + let offset = 0; + let limit = *search_page_size.read(); + match client.search(&q, sort.as_deref(), offset, limit).await { + Ok(resp) => { + search_total.set(resp.total_count); + search_results.set(resp.items); + } + Err(e) => show_toast(format!("Search failed: {e}"), true), + } + loading.set(false); + }); + } + }, + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + if let Ok(item) = client.get_media(&id).await { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + }); + } + }, + on_page_change: { + let client = client.read().clone(); + move |page: u64| { + search_page.set(page); + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = page * *search_page_size.read(); + let limit = *search_page_size.read(); + let q = last_search_query.read().clone(); + let sort = last_search_sort.read().clone(); + match client.search(&q, sort.as_deref(), offset, limit).await { + Ok(resp) => { + search_total.set(resp.total_count); + search_results.set(resp.items); + } + Err(e) => show_toast(format!("Search failed: {e}"), true), + } + loading.set(false); + }); + } + }, + } + }, + 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), + } + }); + } + }, + } + }, + None => rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "No media selected" } + } + }, + } } - } - }, - // Phase 6.2: Scan progress - 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 client = client.clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - import_in_progress.set(true); - 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() { + View::Tags => rsx! { + tags::Tags { + tags: tags_list.read().clone(), + on_create: { + let client = client.read().clone(); + let refresh_tags = refresh_tags.clone(); + move |(name, parent_id): (String, Option)| { + let client = client.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + match client.create_tag(&name, parent_id.as_deref()).await { + Ok(_) => { + show_toast("Tag created".into(), false); refresh_tags(); } + Err(e) => show_toast(format!("Failed: {e}"), true), } - Err(e) => show_toast(format!("Import failed: {e}"), true), - } + }); } - 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 client = client.clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - import_in_progress.set(true); - spawn(async move { - match client.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref()).await { - Ok(resp) => { - show_toast( - format!("Done: {} imported, {} duplicates, {} errors", - resp.imported, resp.duplicates, resp.errors), - resp.errors > 0, - ); - refresh_media(); - if !new_tags.is_empty() { - refresh_tags(); - } - preview_files.set(Vec::new()); - preview_total_size.set(0); - } - Err(e) => show_toast(format!("Directory import failed: {e}"), true), - } - import_in_progress.set(false); - }); - } - }, - // Phase 6.2: Scan with polling for progress - on_scan: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |_| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - import_in_progress.set(true); - spawn(async move { - match client.trigger_scan().await { - Ok(_results) => { - // Poll scan status until done - loop { - match client.scan_status().await { - Ok(status) => { - let done = !status.scanning; - 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, + }, + 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(); } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Err(e) => show_toast(format!("Failed: {e}"), true), } - refresh_media(); - } - Err(e) => show_toast(format!("Scan failed: {e}"), true), + }); } - 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 client = client.clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - let file_count = paths.len(); - import_in_progress.set(true); - spawn(async move { - match client.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref()).await { - Ok(resp) => { - show_toast( - format!("Done: {} imported, {} duplicates, {} errors", - resp.imported, resp.duplicates, resp.errors), - resp.errors > 0, - ); - refresh_media(); - if !new_tags.is_empty() { - refresh_tags(); + View::Collections => rsx! { + collections::Collections { + collections: collections_list.read().clone(), + collection_members: collection_members.read().clone(), + viewing_collection: viewing_collection.read().clone(), + all_media: media_list.read().clone(), + on_create: { + let client = client.read().clone(); + let refresh_collections = refresh_collections.clone(); + move | + (name, kind, desc, filter): (String, String, Option, Option)| + { + let client = client.clone(); + let refresh_collections = refresh_collections.clone(); + spawn(async move { + match client + .create_collection(&name, &kind, desc.as_deref(), filter.as_deref()) + .await + { + Ok(_) => { + show_toast("Collection created".into(), false); + refresh_collections(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), } - preview_files.set(Vec::new()); - preview_total_size.set(0); - } - Err(e) => show_toast(format!("Batch import failed ({file_count} files): {e}"), true), + }); } - import_in_progress.set(false); - }); + }, + 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), + } + }); + } + }, } }, - 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); + 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); + } + }); } - Err(e) => { - show_toast(format!("Preview failed: {e}"), true); - preview_files.set(Vec::new()); - preview_total_size.set(0); + }, + on_page_change: { + let refresh_audit = refresh_audit.clone(); + move |page: u64| { + audit_page.set(page); + refresh_audit(); } - } - }); - } - }, - preview_files: preview_files.read().clone(), - preview_total_size: *preview_total_size.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), + }, + on_filter_change: { + let refresh_audit = refresh_audit.clone(); + move |filter: String| { + audit_filter.set(filter); + audit_page.set(0); + refresh_audit(); + } + }, } - }); + } } - }; - 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(); + 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(); - 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); - // Refresh duplicates list - 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::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| { + move |(path, tag_ids, new_tags, col_id): ImportEvent| { let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + import_in_progress.set(true); spawn(async move { - match client.add_root(&path).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast("Root added".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_remove_root: { - let client = client.read().clone(); - move |path: String| { - let client = client.clone(); - spawn(async move { - match client.remove_root(&path).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast("Root removed".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_toggle_watch: { - let client = client.read().clone(); - move |enabled: bool| { - let client = client.clone(); - spawn(async move { - match client.update_scanning(Some(enabled), None, None).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - let state = if enabled { "enabled" } else { "disabled" }; - show_toast(format!("Watching {state}"), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_update_poll_interval: { - let client = client.read().clone(); - move |secs: u64| { - let client = client.clone(); - spawn(async move { - match client.update_scanning(None, Some(secs), None).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast(format!("Poll interval set to {secs}s"), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_update_ignore_patterns: { - let client = client.read().clone(); - move |patterns: Vec| { - let client = client.clone(); - spawn(async move { - match client.update_scanning(None, None, Some(patterns)).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast("Ignore patterns updated".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_update_ui_config: { - let client = client.read().clone(); - move |updates: serde_json::Value| { - let client = client.clone(); - spawn(async move { - match client.update_ui_config(updates).await { - Ok(ui_cfg) => { - auto_play_media.set(ui_cfg.auto_play_media); - sidebar_collapsed.set(ui_cfg.sidebar_collapsed); - // Reload full config - if let Ok(cfg) = client.get_config().await { - config_data.set(Some(cfg)); + 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(); } - show_toast("UI preferences updated".into(), false); + 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_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 client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + import_in_progress.set(true); + spawn(async move { + match client + .import_directory(&path, &tag_ids, &new_tags, col_id.as_deref()) + .await + { + Ok(resp) => { + show_toast( + format!( + "Done: {} imported, {} duplicates, {} errors", + resp.imported, + resp.duplicates, + resp.errors, + ), + resp.errors > 0, + ); + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + preview_files.set(Vec::new()); + preview_total_size.set(0); + } + Err(e) => show_toast(format!("Directory import failed: {e}"), true), + } + import_in_progress.set(false); + }); + } + }, + on_scan: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |_| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + import_in_progress.set(true); + spawn(async move { + match client.trigger_scan().await { + Ok(_results) => { + loop { + match client.scan_status().await { + Ok(status) => { + let done = !status.scanning; + 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_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 client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + let file_count = paths.len(); + import_in_progress.set(true); + spawn(async move { + match client + .batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref()) + .await + { + Ok(resp) => { + show_toast( + format!( + "Done: {} imported, {} duplicates, {} errors", + resp.imported, + resp.duplicates, + resp.errors, + ), + resp.errors > 0, + ); + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + preview_files.set(Vec::new()); + preview_total_size.set(0); + } + Err(e) => { + show_toast( + format!("Batch import failed ({file_count} files): {e}"), + true, + ) + } + } + 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); } - Err(e) => show_toast(format!("Failed: {e}"), true), } }); } }, + preview_files: preview_files.read().clone(), + preview_total_size: *preview_total_size.read(), } }, - None => rsx! { - div { class: "empty-state", - h3 { class: "empty-title", "Loading settings..." } + 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); + } + }, + } } - }, - } - }, - }} - } - } - - // 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", - 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" } - } - div { class: "shortcut-row", - kbd { "?" } - span { "Toggle this help" } + } + 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::Settings => { + let cfg_ref = config_data.read(); + match cfg_ref.as_ref() { + Some(cfg) => rsx! { + settings::Settings { + config: cfg.clone(), + on_add_root: { + let client = client.read().clone(); + move |path: String| { + let client = client.clone(); + spawn(async move { + match client.add_root(&path).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Root added".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_remove_root: { + let client = client.read().clone(); + move |path: String| { + let client = client.clone(); + spawn(async move { + match client.remove_root(&path).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Root removed".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_toggle_watch: { + let client = client.read().clone(); + move |enabled: bool| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(Some(enabled), None, None).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + let state = if enabled { "enabled" } else { "disabled" }; + show_toast(format!("Watching {state}"), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_poll_interval: { + let client = client.read().clone(); + move |secs: u64| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(None, Some(secs), None).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast(format!("Poll interval set to {secs}s"), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_ignore_patterns: { + let client = client.read().clone(); + move |patterns: Vec| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(None, None, Some(patterns)).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Ignore patterns updated".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_ui_config: { + let client = client.read().clone(); + move |updates: serde_json::Value| { + let client = client.clone(); + spawn(async move { + match client.update_ui_config(updates).await { + Ok(ui_cfg) => { + auto_play_media.set(ui_cfg.auto_play_media); + sidebar_collapsed.set(ui_cfg.sidebar_collapsed); + 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..." } + } + }, + } + } } } - button { - class: "help-close", - onclick: move |_| show_help.set(false), - "Close" + } + } + + // 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", + 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" } + } + div { class: "shortcut-row", + kbd { "?" } + span { "Toggle this help" } + } + } + 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 @@ -1614,12 +1688,8 @@ pub fn App() -> Element { 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}" - } + for (msg , is_error , id) in visible { + div { key: "{id}", class: if is_error { "toast error" } else { "toast success" }, "{msg}" } } } } diff --git a/crates/pinakes-ui/src/components/audit.rs b/crates/pinakes-ui/src/components/audit.rs index df1fab4..b1846c2 100644 --- a/crates/pinakes-ui/src/components/audit.rs +++ b/crates/pinakes-ui/src/components/audit.rs @@ -105,11 +105,7 @@ pub fn AuditLog( } } - PaginationControls { - current_page: audit_page, - total_pages: total_pages, - on_page_change: on_page_change, - } + PaginationControls { current_page: audit_page, total_pages, on_page_change } } } diff --git a/crates/pinakes-ui/src/components/breadcrumb.rs b/crates/pinakes-ui/src/components/breadcrumb.rs index 5e60ef2..1f22f1b 100644 --- a/crates/pinakes-ui/src/components/breadcrumb.rs +++ b/crates/pinakes-ui/src/components/breadcrumb.rs @@ -13,7 +13,7 @@ pub fn Breadcrumb( ) -> Element { rsx! { nav { class: "breadcrumb", - for (i, item) in items.iter().enumerate() { + for (i , item) in items.iter().enumerate() { if i > 0 { span { class: "breadcrumb-sep", " > " } } diff --git a/crates/pinakes-ui/src/components/collections.rs b/crates/pinakes-ui/src/components/collections.rs index bbe5a68..0b7267f 100644 --- a/crates/pinakes-ui/src/components/collections.rs +++ b/crates/pinakes-ui/src/components/collections.rs @@ -44,11 +44,7 @@ pub fn Collections( let modal_col_id = col_id.clone(); return rsx! { - button { - class: "btn btn-ghost mb-16", - onclick: back_click, - "\u{2190} Back to Collections" - } + button { class: "btn btn-ghost mb-16", onclick: back_click, "\u{2190} Back to Collections" } h3 { class: "mb-16", "{col_name}" } @@ -88,10 +84,7 @@ pub fn Collections( move |_| on_select.call(mid.clone()) }; rsx! { - tr { - key: "{item.id}", - class: "clickable-row", - onclick: row_click, + tr { key: "{item.id}", class: "clickable-row", onclick: row_click, td { "{item.file_name}" } td { span { class: "type-badge {badge_class}", "{item.media_type}" } @@ -118,9 +111,11 @@ pub fn Collections( // Add Media modal if *show_add_modal.read() { - div { class: "modal-overlay", + div { + class: "modal-overlay", onclick: move |_| show_add_modal.set(false), - div { class: "modal", + div { + class: "modal", onclick: move |e: Event| e.stop_propagation(), div { class: "modal-header", h3 { "Add Media to Collection" } @@ -156,10 +151,7 @@ pub fn Collections( } }; rsx! { - tr { - key: "{media.id}", - class: "clickable-row", - onclick: add_click, + tr { key: "{media.id}", class: "clickable-row", onclick: add_click, td { "{media.file_name}" } td { span { class: "type-badge {badge_class}", "{media.media_type}" } @@ -242,11 +234,7 @@ pub fn Collections( } div { class: "form-row mb-16", - button { - class: "btn btn-primary", - onclick: create_click, - "Create" - } + button { class: "btn btn-primary", onclick: create_click, "Create" } } if collections.is_empty() { @@ -268,7 +256,11 @@ pub fn Collections( for col in collections.iter() { { let desc = col.description.clone().unwrap_or_default(); - let kind_class = if col.kind == "virtual" { "type-document" } else { "type-other" }; + let kind_class = if col.kind == "virtual" { + "type-document" + } else { + "type-other" + }; let view_click = { let id = col.id.clone(); move |_| on_view_members.call(id.clone()) @@ -287,11 +279,7 @@ pub fn Collections( } td { "{desc}" } td { - button { - class: "btn btn-sm btn-secondary", - onclick: view_click, - "View" - } + button { class: "btn btn-sm btn-secondary", onclick: view_click, "View" } } td { if is_confirming { diff --git a/crates/pinakes-ui/src/components/database.rs b/crates/pinakes-ui/src/components/database.rs index 32870a7..4a71e33 100644 --- a/crates/pinakes-ui/src/components/database.rs +++ b/crates/pinakes-ui/src/components/database.rs @@ -57,7 +57,7 @@ pub fn Database( } } } - }, + } None => rsx! { div { class: "empty-state", p { class: "text-muted", "Loading database stats..." } @@ -162,7 +162,9 @@ pub fn Database( } if *confirm_clear.read() { div { class: "db-action-confirm", - span { class: "text-sm", style: "color: var(--danger);", + span { + class: "text-sm", + style: "color: var(--danger);", "This will delete everything. Are you sure?" } button { diff --git a/crates/pinakes-ui/src/components/detail.rs b/crates/pinakes-ui/src/components/detail.rs index 35e1d56..f8a7765 100644 --- a/crates/pinakes-ui/src/components/detail.rs +++ b/crates/pinakes-ui/src/components/detail.rs @@ -231,14 +231,14 @@ pub fn Detail( media_type: "audio".to_string(), title: media.title.clone(), thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None }, - autoplay: autoplay, + autoplay, } } else if category == "video" { MediaPlayer { src: stream_url.clone(), media_type: "video".to_string(), title: media.title.clone(), - autoplay: autoplay, + autoplay, } } else if category == "image" { if has_thumbnail { @@ -298,40 +298,16 @@ pub fn Detail( "Open" } if is_editing { - button { - class: "btn btn-primary", - onclick: on_save_click, - "Save" - } - button { - class: "btn btn-ghost", - onclick: on_cancel_click, - "Cancel" - } + button { class: "btn btn-primary", onclick: on_save_click, "Save" } + button { class: "btn btn-ghost", onclick: on_cancel_click, "Cancel" } } else { - button { - class: "btn btn-secondary", - onclick: on_edit_click, - "Edit" - } + button { class: "btn btn-secondary", onclick: on_edit_click, "Edit" } } if confirm_delete() { - button { - class: "btn btn-danger", - onclick: on_confirm_delete, - "Confirm Delete" - } - button { - class: "btn btn-ghost", - onclick: on_cancel_delete, - "Cancel" - } + button { class: "btn btn-danger", onclick: on_confirm_delete, "Confirm Delete" } + button { class: "btn btn-ghost", onclick: on_cancel_delete, "Cancel" } } else { - button { - class: "btn btn-danger", - onclick: on_delete_click, - "Delete" - } + button { class: "btn btn-danger", onclick: on_delete_click, "Delete" } } } @@ -373,11 +349,13 @@ pub fn Detail( } div { class: "detail-field", label { class: "detail-label", - {match category { - "image" => "Photographer", - "document" | "text" => "Author", - _ => "Artist", - }} + { + match category { + "image" => "Photographer", + "document" | "text" => "Author", + _ => "Artist", + } + } } input { r#type: "text", @@ -458,11 +436,13 @@ pub fn Detail( if !artist.is_empty() { div { class: "detail-field", span { class: "detail-label", - {match category { - "image" => "Photographer", - "document" | "text" => "Author", - _ => "Artist", - }} + { + match category { + "image" => "Photographer", + "document" | "text" => "Author", + _ => "Artist", + } + } } span { class: "detail-value", "{artist}" } } @@ -482,7 +462,9 @@ pub fn Detail( } } // Year: audio, video, document, when non-empty - if (category == "audio" || category == "video" || category == "document") && !year_str.is_empty() { + if (category == "audio" || category == "video" || category == "document") + && !year_str.is_empty() + { div { class: "detail-field", span { class: "detail-label", "Year" } span { class: "detail-value", "{year_str}" } @@ -524,9 +506,7 @@ pub fn Detail( let tag_id = tag.id.clone(); let media_id_untag = id.clone(); rsx! { - span { - class: "tag-badge", - key: "{tag_id}", + span { class: "tag-badge", key: "{tag_id}", "{tag.name}" span { class: "tag-remove", @@ -552,11 +532,7 @@ pub fn Detail( let tid = tag.id.clone(); let tname = tag.name.clone(); rsx! { - option { - key: "{tid}", - value: "{tid}", - "{tname}" - } + option { key: "{tid}", value: "{tid}", "{tname}" } } } } @@ -576,10 +552,8 @@ pub fn Detail( h4 { class: "card-title", "Technical Metadata" } } div { class: "detail-grid", - for (key, _field_type, value) in system_fields.iter() { - div { - class: "detail-field", - key: "{key}", + for (key , _field_type , value) in system_fields.iter() { + div { class: "detail-field", key: "{key}", span { class: "detail-label", "{key}" } span { class: "detail-value", "{value}" } } @@ -595,14 +569,12 @@ pub fn Detail( } if has_user_fields { div { class: "detail-grid", - for (key, field_type, value) in user_fields.iter() { + for (key , field_type , value) in user_fields.iter() { { let field_name = key.clone(); let media_id_del = id.clone(); rsx! { - div { - class: "detail-field", - key: "{field_name}", + div { class: "detail-field", key: "{field_name}", span { class: "detail-label", "{key} ({field_type})" } div { class: "flex-row", span { class: "detail-value", "{value}" } diff --git a/crates/pinakes-ui/src/components/duplicates.rs b/crates/pinakes-ui/src/components/duplicates.rs index 4b97562..85718db 100644 --- a/crates/pinakes-ui/src/components/duplicates.rs +++ b/crates/pinakes-ui/src/components/duplicates.rs @@ -44,7 +44,20 @@ pub fn Duplicates( let is_expanded = expanded_group.read().as_ref() == Some(&hash); let hash_for_toggle = hash.clone(); let item_count = group.items.len(); - let first_name = group.items.first() + let first_name = group + .items + + // Group header + + // Expanded: show items + + // Thumbnail + + // Info + + // Actions + + .first() .map(|i| i.file_name.clone()) .unwrap_or_default(); let total_size: u64 = group.items.iter().map(|i| i.file_size).sum(); @@ -53,13 +66,9 @@ pub fn Duplicates( } else { hash.clone() }; - rsx! { - div { - class: "duplicate-group", - key: "{hash}", + div { class: "duplicate-group", key: "{hash}", - // Group header button { class: "duplicate-group-header", onclick: move |_| { @@ -71,20 +80,21 @@ pub fn Duplicates( } }, span { class: "expand-icon", - if is_expanded { "\u{25bc}" } else { "\u{25b6}" } + if is_expanded { + "\u{25bc}" + } else { + "\u{25b6}" + } } span { class: "group-name", "{first_name}" } span { class: "group-badge", "{item_count} files" } span { class: "group-size text-muted", "{format_size(total_size)}" } - span { class: "group-hash mono text-muted", - "{short_hash}" - } + span { class: "group-hash mono text-muted", "{short_hash}" } } - // Expanded: show items if is_expanded { div { class: "duplicate-items", - for (idx, item) in group.items.iter().enumerate() { + for (idx , item) in group.items.iter().enumerate() { { let item_id = item.id.clone(); let is_first = idx == 0; @@ -97,7 +107,10 @@ pub fn Duplicates( class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" }, key: "{item_id}", - // Thumbnail + + + + div { class: "dup-thumb", if has_thumb { img { @@ -110,7 +123,6 @@ pub fn Duplicates( } } - // Info div { class: "dup-info", div { class: "dup-filename", "{item.file_name}" } div { class: "dup-path mono text-muted", "{item.path}" } @@ -121,7 +133,6 @@ pub fn Duplicates( } } - // Actions div { class: "dup-actions", if is_first { span { class: "keep-badge", "Keep" } diff --git a/crates/pinakes-ui/src/components/image_viewer.rs b/crates/pinakes-ui/src/components/image_viewer.rs index db3fc1c..d177593 100644 --- a/crates/pinakes-ui/src/components/image_viewer.rs +++ b/crates/pinakes-ui/src/components/image_viewer.rs @@ -194,10 +194,18 @@ pub fn ImageViewer( } } div { class: "image-viewer-toolbar-center", - button { class: "iv-btn", onclick: cycle_fit, title: "Cycle fit mode", + button { + class: "iv-btn", + onclick: cycle_fit, + title: "Cycle fit mode", "{current_fit.label()}" } - button { class: "iv-btn", onclick: zoom_out, title: "Zoom out", "\u{2212}" } + button { + class: "iv-btn", + onclick: zoom_out, + title: "Zoom out", + "\u{2212}" + } span { class: "iv-zoom-label", "{zoom_pct}%" } button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" } } diff --git a/crates/pinakes-ui/src/components/import.rs b/crates/pinakes-ui/src/components/import.rs index 0963565..04c4e70 100644 --- a/crates/pinakes-ui/src/components/import.rs +++ b/crates/pinakes-ui/src/components/import.rs @@ -148,7 +148,11 @@ pub fn Import( } } }, - if is_importing { "Importing..." } else { "Import" } + if is_importing { + "Importing..." + } else { + "Import" + } } } } @@ -157,9 +161,9 @@ pub fn Import( ImportOptions { tags: tags.clone(), collections: collections.clone(), - selected_tags: selected_tags, - new_tags_input: new_tags_input, - selected_collection: selected_collection, + selected_tags, + new_tags_input, + selected_collection, } } @@ -244,32 +248,48 @@ pub fn Import( let min = *filter_min_size.read(); let max = *filter_max_size.read(); - let filtered: Vec<&DirectoryPreviewFile> = preview_files.iter().filter(|f| { - let type_idx = match type_badge_class(&f.media_type) { - "type-audio" => 0, - "type-video" => 1, - "type-image" => 2, - "type-document" => 3, - "type-text" => 4, - _ => 5, - }; - if !types_snapshot[type_idx] { return false; } - if min > 0 && f.file_size < min { return false; } - if max > 0 && f.file_size > max { return false; } - true - }).collect(); + let filtered: Vec<&DirectoryPreviewFile> = preview_files + // Read selection once for display + .iter() + + // Filter bar + + // Selection toolbar + + // Deselect all filtered + // Select all filtered + .filter(|f| { + let type_idx = match type_badge_class(&f.media_type) { + "type-audio" => 0, + "type-video" => 1, + "type-image" => 2, + "type-document" => 3, + "type-text" => 4, + _ => 5, + }; + if !types_snapshot[type_idx] { + return false; + } + if min > 0 && f.file_size < min { + return false; + } + if max > 0 && f.file_size > max { + return false; + } + true + }) + .collect(); let filtered_count = filtered.len(); let total_count = preview_files.len(); - - // Read selection once for display let selection = selected_file_paths.read().clone(); let selected_count = selection.len(); let all_filtered_selected = !filtered.is_empty() && filtered.iter().all(|f| selection.contains(&f.path)); - - let filtered_paths: Vec = filtered.iter().map(|f| f.path.clone()).collect(); - + let filtered_paths: Vec = filtered + .iter() + .map(|f| f.path.clone()) + .collect(); rsx! { div { class: "card mb-16", div { class: "card-header", @@ -279,7 +299,6 @@ pub fn Import( } } - // Filter bar div { class: "filter-bar", div { class: "flex-row mb-8", label { @@ -383,8 +402,9 @@ pub fn Import( } } - // Selection toolbar - div { class: "flex-row mb-8", style: "gap: 8px; align-items: center; padding: 0 8px;", + div { + class: "flex-row mb-8", + style: "gap: 8px; align-items: center; padding: 0 8px;", button { class: "btn btn-sm btn-secondary", onclick: { @@ -407,9 +427,7 @@ pub fn Import( "Deselect All" } if selected_count > 0 { - span { class: "text-muted text-sm", - "{selected_count} files selected" - } + span { class: "text-muted text-sm", "{selected_count} files selected" } } } @@ -425,13 +443,17 @@ pub fn Import( let filtered_paths = filtered_paths.clone(); move |_| { if all_filtered_selected { - // Deselect all filtered - let filtered_set: HashSet = filtered_paths.iter().cloned().collect(); + let filtered_set: HashSet = filtered_paths + .iter() + .cloned() + .collect(); let sel = selected_file_paths.read().clone(); - let new_sel: HashSet = sel.difference(&filtered_set).cloned().collect(); + let new_sel: HashSet = sel + .difference(&filtered_set) + .cloned() + .collect(); selected_file_paths.set(new_sel); } else { - // Select all filtered let mut sel = selected_file_paths.read().clone(); for p in &filtered_paths { sel.insert(p.clone()); @@ -455,9 +477,7 @@ pub fn Import( let is_selected = selection.contains(&file.path); let file_path_clone = file.path.clone(); rsx! { - tr { - key: "{file.path}", - class: if is_selected { "row-selected" } else { "" }, + tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" }, td { input { r#type: "checkbox", @@ -496,9 +516,9 @@ pub fn Import( ImportOptions { tags: tags.clone(), collections: collections.clone(), - selected_tags: selected_tags, - new_tags_input: new_tags_input, - selected_collection: selected_collection, + selected_tags, + new_tags_input, + selected_collection, } div { class: "flex-row mb-16", style: "gap: 8px;", @@ -516,7 +536,11 @@ pub fn Import( let mut new_tags_input = new_tags_input; let mut selected_collection = selected_collection; move |_| { - let paths: Vec = selected_file_paths.read().iter().cloned().collect(); + let paths: Vec = selected_file_paths + .read() + .iter() + .cloned() + .collect(); if !paths.is_empty() { let tag_ids = selected_tags.read().clone(); let new_tags = parse_new_tags(&new_tags_input.read()); @@ -565,7 +589,11 @@ pub fn Import( } } }, - if is_importing { "Importing..." } else { "Import Entire Directory" } + if is_importing { + "Importing..." + } else { + "Import Entire Directory" + } } } } @@ -589,36 +617,37 @@ pub fn Import( class: "btn btn-primary", disabled: is_importing, onclick: move |_| on_scan.call(()), - if is_importing { "Scanning..." } else { "Scan All Roots" } + if is_importing { + "Scanning..." + } else { + "Scan All Roots" + } } } if let Some(ref progress) = scan_progress { { - let pct = (progress.files_processed * 100).checked_div(progress.files_found).unwrap_or(0); + let pct = (progress.files_processed * 100) + .checked_div(progress.files_found) + .unwrap_or(0); rsx! { - div { class: "mb-16", - div { class: "progress-bar", - div { - class: "progress-fill", - style: "width: {pct}%;", + div { class: "mb-16", + div { class: "progress-bar", + div { class: "progress-fill", style: "width: {pct}%;" } + } + p { class: "text-muted text-sm", + "{progress.files_processed} / {progress.files_found} files processed" + } + if progress.error_count > 0 { + p { class: "text-muted text-sm", "{progress.error_count} errors" } + } + if progress.scanning { + p { class: "text-muted text-sm", "Scanning..." } + } else { + p { class: "text-muted text-sm", "Scan complete" } + } } } - p { class: "text-muted text-sm", - "{progress.files_processed} / {progress.files_found} files processed" - } - if progress.error_count > 0 { - p { class: "text-muted text-sm", - "{progress.error_count} errors" - } - } - if progress.scanning { - p { class: "text-muted text-sm", "Scanning..." } - } else { - p { class: "text-muted text-sm", "Scan complete" } - } - } - } } } } @@ -647,7 +676,9 @@ fn ImportOptions( div { class: "form-group", label { class: "form-label", "Tags" } if tags.is_empty() { - p { class: "text-muted text-sm", "No tags available. Create tags from the Tags page." } + p { class: "text-muted text-sm", + "No tags available. Create tags from the Tags page." + } } else { div { class: "tag-list", for tag in tags.iter() { @@ -655,11 +686,7 @@ fn ImportOptions( let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); let is_selected = selected_tags.read().contains(&tag_id); - let badge_class = if is_selected { - "tag-badge selected" - } else { - "tag-badge" - }; + let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; rsx! { span { class: "{badge_class}", @@ -693,7 +720,9 @@ fn ImportOptions( value: "{new_tags_input}", oninput: move |e| new_tags_input.set(e.value()), } - p { class: "text-muted text-sm", "Comma-separated. Will be created if they don't exist." } + p { class: "text-muted text-sm", + "Comma-separated. Will be created if they don't exist." + } } div { class: "form-group", diff --git a/crates/pinakes-ui/src/components/library.rs b/crates/pinakes-ui/src/components/library.rs index fa93623..0f538d9 100644 --- a/crates/pinakes-ui/src/components/library.rs +++ b/crates/pinakes-ui/src/components/library.rs @@ -107,7 +107,9 @@ pub fn Library( return rsx! { div { class: "empty-state", h3 { class: "empty-title", "No media found" } - p { class: "empty-subtitle", "Import files or scan your root directories to get started." } + p { class: "empty-subtitle", + "Import files or scan your root directories to get started." + } } }; } @@ -153,12 +155,16 @@ pub fn Library( rsx! { // Confirmation dialog for single delete if confirm_delete.read().is_some() { - div { class: "modal-overlay", + div { + class: "modal-overlay", onclick: move |_| confirm_delete.set(None), - div { class: "modal", + div { + class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Confirm Delete" } - p { class: "modal-body", "Are you sure you want to delete this media item? This cannot be undone." } + p { class: "modal-body", + "Are you sure you want to delete this media item? This cannot be undone." + } div { class: "modal-actions", button { class: "btn btn-ghost", @@ -182,9 +188,11 @@ pub fn Library( // Confirmation dialog for batch delete if *confirm_batch_delete.read() { - div { class: "modal-overlay", + div { + class: "modal-overlay", onclick: move |_| confirm_batch_delete.set(false), - div { class: "modal", + div { + class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Confirm Batch Delete" } p { class: "modal-body", @@ -214,9 +222,11 @@ pub fn Library( // Confirmation dialog for delete all if *confirm_delete_all.read() { - div { class: "modal-overlay", + div { + class: "modal-overlay", onclick: move |_| confirm_delete_all.set(false), - div { class: "modal", + div { + class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Delete All Media" } p { class: "modal-body", @@ -248,12 +258,14 @@ pub fn Library( // Batch tag dialog if *show_batch_tag.read() { - div { class: "modal-overlay", + div { + class: "modal-overlay", onclick: move |_| { show_batch_tag.set(false); batch_tag_selection.set(Vec::new()); }, - div { class: "modal", + div { + class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Tag Selected Items" } p { class: "modal-body text-muted text-sm", @@ -322,12 +334,14 @@ pub fn Library( // Batch collection dialog if *show_batch_collection.read() { - div { class: "modal-overlay", + div { + class: "modal-overlay", onclick: move |_| { show_batch_collection.set(false); batch_collection_id.set(String::new()); }, - div { class: "modal", + div { + class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Add to Collection" } p { class: "modal-body text-muted text-sm", @@ -342,11 +356,7 @@ pub fn Library( onchange: move |e: Event| batch_collection_id.set(e.value()), option { value: "", "Select a collection..." } for col in collections.iter() { - option { - key: "{col.id}", - value: "{col.id}", - "{col.name}" - } + option { key: "{col.id}", value: "{col.id}", "{col.name}" } } } } @@ -497,9 +507,7 @@ pub fn Library( "Delete All" } } - span { class: "text-muted text-sm", - "{total_count} items" - } + span { class: "text-muted text-sm", "{total_count} items" } } } @@ -537,9 +545,7 @@ pub fn Library( "Showing {filtered_count} items" } } - span { class: "text-muted text-sm", - "Page {current_page + 1} of {total_pages}" - } + span { class: "text-muted text-sm", "Page {current_page + 1} of {total_pages}" } } // Select-all banner: when all items on this page are selected and there @@ -551,10 +557,13 @@ pub fn Library( button { onclick: move |_| { if let Some(handler) = on_select_all_global { - handler.call(EventHandler::new(move |all_ids: Vec| { - selected_ids.set(all_ids); - global_all_selected.set(true); - })); + handler + .call( + EventHandler::new(move |all_ids: Vec| { + selected_ids.set(all_ids); + global_all_selected.set(true); + }), + ); } }, "Select all {total_count} items" @@ -580,29 +589,45 @@ pub fn Library( match current_mode { ViewMode::Grid => rsx! { div { class: "media-grid", - for (idx, item) in filtered_media.iter().enumerate() { + for (idx , item) in filtered_media.iter().enumerate() { { let id = item.id.clone(); let badge_class = type_badge_class(&item.media_type); let is_checked = current_selection.contains(&id); + + + // Build a list of all visible IDs for shift+click range selection. + + // Shift+click: select range from last_click_index to current idx. + // No previous click, just toggle this one. + + // Thumbnail with CSS fallback: both the icon and img + // are rendered. The img is absolutely positioned on + // top. If the image fails to load, the icon beneath + // shows through. + + // Thumbnail with CSS fallback: icon always + // rendered, img overlays when available. let card_click = { let id = item.id.clone(); move |_| on_select.call(id.clone()) }; - // Build a list of all visible IDs for shift+click range selection. - let visible_ids: Vec = filtered_media.iter().map(|m| m.id.clone()).collect(); + let visible_ids: Vec = filtered_media + + + .iter() + .map(|m| m.id.clone()) + .collect(); let toggle_id = { let id = id.clone(); move |e: Event| { e.stop_propagation(); let shift = e.modifiers().shift(); let mut ids = selected_ids.read().clone(); - if shift { - // Shift+click: select range from last_click_index to current idx. if let Some(last) = *last_click_index.read() { let start = last.min(idx); let end = last.max(idx); @@ -614,7 +639,6 @@ pub fn Library( } } } else { - // No previous click, just toggle this one. if !ids.contains(&id) { ids.push(id.clone()); } @@ -624,12 +648,10 @@ pub fn Library( } else { ids.push(id.clone()); } - last_click_index.set(Some(idx)); selected_ids.set(ids); } }; - let thumb_url = if item.has_thumbnail { format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) } else { @@ -640,29 +662,15 @@ pub fn Library( let card_class = if is_checked { "media-card selected" } else { "media-card" }; let title_text = item.title.clone().unwrap_or_default(); let artist_text = item.artist.clone().unwrap_or_default(); - rsx! { - div { - key: "{item.id}", - class: "{card_class}", - onclick: card_click, + div { key: "{item.id}", class: "{card_class}", onclick: card_click, div { class: "card-checkbox", - input { - r#type: "checkbox", - checked: is_checked, - onclick: toggle_id, - } + input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } } - // Thumbnail with CSS fallback: both the icon and img - // are rendered. The img is absolutely positioned on - // top. If the image fails to load, the icon beneath - // shows through. div { class: "card-thumbnail", - div { class: "card-type-icon {badge_class}", - "{type_icon(&media_type)}" - } + div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" } if has_thumb { img { class: "card-thumb-img", @@ -674,18 +682,12 @@ pub fn Library( } div { class: "card-info", - div { class: "card-name", title: "{item.file_name}", - "{item.file_name}" - } + div { class: "card-name", title: "{item.file_name}", "{item.file_name}" } if !title_text.is_empty() { - div { class: "card-title text-muted text-xs", - "{title_text}" - } + div { class: "card-title text-muted text-xs", "{title_text}" } } if !artist_text.is_empty() { - div { class: "card-artist text-muted text-xs", - "{artist_text}" - } + div { class: "card-artist text-muted text-xs", "{artist_text}" } } div { class: "card-meta", span { class: "type-badge {badge_class}", "{item.media_type}" } @@ -751,7 +753,7 @@ pub fn Library( } } tbody { - for (idx, item) in filtered_media.iter().enumerate() { + for (idx , item) in filtered_media.iter().enumerate() { { let id = item.id.clone(); let artist = item.artist.clone().unwrap_or_default(); @@ -759,15 +761,17 @@ pub fn Library( let badge_class = type_badge_class(&item.media_type); let is_checked = current_selection.contains(&id); - let visible_ids: Vec = filtered_media.iter().map(|m| m.id.clone()).collect(); + let visible_ids: Vec = filtered_media + .iter() + .map(|m| m.id.clone()) + .collect(); let toggle_id = { let id = id.clone(); move |e: Event| { e.stop_propagation(); let shift = e.modifiers().shift(); let mut ids = selected_ids.read().clone(); - if shift { if let Some(last) = *last_click_index.read() { let start = last.min(idx); @@ -789,17 +793,14 @@ pub fn Library( } else { ids.push(id.clone()); } - last_click_index.set(Some(idx)); selected_ids.set(ids); } }; - let row_click = { let id = item.id.clone(); move |_| on_select.call(id.clone()) }; - let delete_click = { let id = item.id.clone(); move |e: Event| { @@ -807,7 +808,6 @@ pub fn Library( confirm_delete.set(Some(id.clone())); } }; - let thumb_url = if item.has_thumbnail { format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) } else { @@ -815,24 +815,13 @@ pub fn Library( }; let has_thumb = item.has_thumbnail; let media_type_str = item.media_type.clone(); - rsx! { - tr { - key: "{item.id}", - onclick: row_click, + tr { key: "{item.id}", onclick: row_click, td { - input { - r#type: "checkbox", - checked: is_checked, - onclick: toggle_id, - } + input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } } td { class: "table-thumb-cell", - // Thumbnail with CSS fallback: icon always - // rendered, img overlays when available. - span { class: "table-type-icon {badge_class}", - "{type_icon(&media_type_str)}" - } + span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" } if has_thumb { img { class: "table-thumb table-thumb-overlay", @@ -849,11 +838,7 @@ pub fn Library( td { "{artist}" } td { "{size}" } td { - button { - class: "btn btn-danger btn-sm", - onclick: delete_click, - "Delete" - } + button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" } } } } diff --git a/crates/pinakes-ui/src/components/login.rs b/crates/pinakes-ui/src/components/login.rs index e9f028d..170f8ca 100644 --- a/crates/pinakes-ui/src/components/login.rs +++ b/crates/pinakes-ui/src/components/login.rs @@ -66,7 +66,11 @@ pub fn Login( class: "btn btn-primary login-btn", disabled: loading, onclick: on_submit, - if loading { "Signing in..." } else { "Sign In" } + if loading { + "Signing in..." + } else { + "Sign In" + } } } } diff --git a/crates/pinakes-ui/src/components/media_player.rs b/crates/pinakes-ui/src/components/media_player.rs index 2294506..e5a3919 100644 --- a/crates/pinakes-ui/src/components/media_player.rs +++ b/crates/pinakes-ui/src/components/media_player.rs @@ -498,10 +498,14 @@ pub fn QueuePanel( div { class: "queue-empty", "Queue is empty. Add items from the library." } } else { div { class: "queue-list", - for (i, item) in queue.items.iter().enumerate() { + for (i , item) in queue.items.iter().enumerate() { { let is_current = i == current_idx; - let item_class = if is_current { "queue-item queue-item-active" } else { "queue-item" }; + let item_class = if is_current { + "queue-item queue-item-active" + } else { + "queue-item" + }; let title = item.title.clone(); let artist = item.artist.clone().unwrap_or_default(); rsx! { diff --git a/crates/pinakes-ui/src/components/search.rs b/crates/pinakes-ui/src/components/search.rs index 4fcaa4c..be19fad 100644 --- a/crates/pinakes-ui/src/components/search.rs +++ b/crates/pinakes-ui/src/components/search.rs @@ -75,9 +75,7 @@ pub fn Search( oninput: move |e| query.set(e.value()), onkeypress: on_key, } - select { - value: "{sort_by}", - onchange: move |e| sort_by.set(e.value()), + select { value: "{sort_by}", onchange: move |e| sort_by.set(e.value()), option { value: "relevance", "Relevance" } option { value: "date_desc", "Newest" } option { value: "date_asc", "Oldest" } @@ -86,16 +84,8 @@ pub fn Search( option { value: "size_desc", "Size (largest)" } option { value: "size_asc", "Size (smallest)" } } - button { - class: "btn btn-primary", - onclick: do_search, - "Search" - } - button { - class: "btn btn-ghost", - onclick: toggle_help, - "Syntax Help" - } + button { class: "btn btn-primary", onclick: do_search, "Search" } + button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" } // View mode toggle div { class: "view-toggle", @@ -118,15 +108,42 @@ pub fn Search( div { class: "card mb-16", h4 { "Search Syntax" } ul { - li { code { "hello world" } " -- full text search (implicit AND)" } - li { code { "artist:Beatles" } " -- field match" } - li { code { "type:pdf" } " -- filter by media type" } - li { code { "tag:music" } " -- filter by tag" } - li { code { "hello OR world" } " -- OR operator" } - li { code { "-excluded" } " -- NOT (exclude term)" } - li { code { "hel*" } " -- prefix search" } - li { code { "hello~" } " -- fuzzy search" } - li { code { "\"exact phrase\"" } " -- quoted exact match" } + li { + code { "hello world" } + " -- full text search (implicit AND)" + } + li { + code { "artist:Beatles" } + " -- field match" + } + li { + code { "type:pdf" } + " -- filter by media type" + } + li { + code { "tag:music" } + " -- filter by tag" + } + li { + code { "hello OR world" } + " -- OR operator" + } + li { + code { "-excluded" } + " -- NOT (exclude term)" + } + li { + code { "hel*" } + " -- prefix search" + } + li { + code { "hello~" } + " -- fuzzy search" + } + li { + code { "\"exact phrase\"" } + " -- quoted exact match" + } } } } @@ -136,7 +153,9 @@ pub fn Search( if results.is_empty() && query.read().is_empty() { div { class: "empty-state", h3 { class: "empty-title", "Search your media" } - p { class: "empty-subtitle", "Enter a query above to find files by name, metadata, tags, or type." } + p { class: "empty-subtitle", + "Enter a query above to find files by name, metadata, tags, or type." + } } } @@ -159,6 +178,8 @@ pub fn Search( move |_| on_select.call(id.clone()) }; + + let thumb_url = if item.has_thumbnail { format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) } else { @@ -168,10 +189,8 @@ pub fn Search( let media_type = item.media_type.clone(); rsx! { - div { - key: "{item.id}", - class: "media-card", - onclick: card_click, + + div { key: "{item.id}", class: "media-card", onclick: card_click, div { class: "card-thumbnail", if has_thumb { @@ -181,16 +200,12 @@ pub fn Search( loading: "lazy", } } else { - div { class: "card-type-icon {badge_class}", - "{type_icon(&media_type)}" - } + div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" } } } div { class: "card-info", - div { class: "card-name", title: "{item.file_name}", - "{item.file_name}" - } + div { class: "card-name", title: "{item.file_name}", "{item.file_name}" } div { class: "card-meta", span { class: "type-badge {badge_class}", "{item.media_type}" } span { class: "card-size", "{format_size(item.file_size)}" } @@ -223,9 +238,7 @@ pub fn Search( move |_| on_select.call(id.clone()) }; rsx! { - tr { - key: "{item.id}", - onclick: row_click, + tr { key: "{item.id}", onclick: row_click, td { "{item.file_name}" } td { span { class: "type-badge {badge_class}", "{item.media_type}" } @@ -242,10 +255,6 @@ pub fn Search( } // Pagination controls - PaginationControls { - current_page: search_page, - total_pages: total_pages, - on_page_change: on_page_change, - } + PaginationControls { current_page: search_page, total_pages, on_page_change } } } diff --git a/crates/pinakes-ui/src/components/settings.rs b/crates/pinakes-ui/src/components/settings.rs index edde519..2fa5445 100644 --- a/crates/pinakes-ui/src/components/settings.rs +++ b/crates/pinakes-ui/src/components/settings.rs @@ -65,7 +65,9 @@ pub fn Settings( label { class: "form-label", "Backend" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "The storage backend used by the server (SQLite or PostgreSQL)." } + span { class: "tooltip-text", + "The storage backend used by the server (SQLite or PostgreSQL)." + } } } span { class: "info-value badge badge-neutral", "{config.backend}" } @@ -75,7 +77,9 @@ pub fn Settings( label { class: "form-label", "Server Address" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "The address and port the server is listening on." } + span { class: "tooltip-text", + "The address and port the server is listening on." + } } } span { class: "info-value mono", "{host_port}" } @@ -85,7 +89,9 @@ pub fn Settings( label { class: "form-label", "Database Path" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "File path to the SQLite database, or connection info for PostgreSQL." } + span { class: "tooltip-text", + "File path to the SQLite database, or connection info for PostgreSQL." + } } } span { class: "info-value mono", "{db_path}" } @@ -102,7 +108,9 @@ pub fn Settings( } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "Directories that Pinakes scans for media files. Only existing directories can be added." } + span { class: "tooltip-text", + "Directories that Pinakes scans for media files. Only existing directories can be added." + } } } div { class: "settings-card-body", @@ -194,7 +202,9 @@ pub fn Settings( label { class: "form-label", "File Watching" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events." } + span { class: "tooltip-text", + "When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events." + } } } div { @@ -204,8 +214,7 @@ pub fn Settings( on_toggle_watch.call(!watch_enabled); } }, - div { - class: if watch_enabled { "toggle-track active" } else { "toggle-track" }, + div { class: if watch_enabled { "toggle-track active" } else { "toggle-track" }, div { class: "toggle-thumb" } } } @@ -217,7 +226,9 @@ pub fn Settings( label { class: "form-label", "Poll Interval" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback." } + span { class: "tooltip-text", + "How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback." + } } } if *editing_poll.read() { @@ -242,7 +253,8 @@ pub fn Settings( poll_error.set(None); } _ => { - poll_error.set(Some("Enter a positive integer (seconds).".to_string())); + poll_error + .set(Some("Enter a positive integer (seconds).".to_string())); } } } @@ -307,7 +319,9 @@ pub fn Settings( label { class: "form-label", "Ignore Patterns" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "Glob patterns for files and directories to skip during scanning. One pattern per line." } + span { class: "tooltip-text", + "Glob patterns for files and directories to skip during scanning. One pattern per line." + } } } if *editing_patterns.read() { @@ -359,7 +373,9 @@ pub fn Settings( class: "patterns-textarea", placeholder: "One pattern per line, e.g.:\n*.tmp\n.git/**\nnode_modules/**", } - p { class: "text-muted text-sm", "Enter one glob pattern per line. Empty lines are ignored." } + p { class: "text-muted text-sm", + "Enter one glob pattern per line. Empty lines are ignored." + } } } else { if config.scanning.ignore_patterns.is_empty() { @@ -397,7 +413,7 @@ pub fn Settings( let handler = on_update_ui_config; move |e: Event| { if let Some(ref h) = handler { - h.call(serde_json::json!({"theme": e.value()})); + h.call(serde_json::json!({ "theme" : e.value() })); } } }, @@ -412,7 +428,9 @@ pub fn Settings( label { class: "form-label", "Default View" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "The view shown when the application starts." } + span { class: "tooltip-text", + "The view shown when the application starts." + } } } select { @@ -421,7 +439,7 @@ pub fn Settings( let handler = on_update_ui_config; move |e: Event| { if let Some(ref h) = handler { - h.call(serde_json::json!({"default_view": e.value()})); + h.call(serde_json::json!({ "default_view" : e.value() })); } } }, @@ -436,7 +454,9 @@ pub fn Settings( label { class: "form-label", "Default Page Size" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "Number of items shown per page by default." } + span { class: "tooltip-text", + "Number of items shown per page by default." + } } } select { @@ -444,10 +464,9 @@ pub fn Settings( onchange: { let handler = on_update_ui_config; move |e: Event| { - if let Some(ref h) = handler - && let Ok(size) = e.value().parse::() { - h.call(serde_json::json!({"default_page_size": size})); - } + if let Some(ref h) = handler && let Ok(size) = e.value().parse::() { + h.call(serde_json::json!({ "default_page_size" : size })); + } } }, option { value: "24", "24" } @@ -463,7 +482,9 @@ pub fn Settings( label { class: "form-label", "Default View Mode" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "Whether to show items in a grid or table layout." } + span { class: "tooltip-text", + "Whether to show items in a grid or table layout." + } } } select { @@ -472,7 +493,7 @@ pub fn Settings( let handler = on_update_ui_config; move |e: Event| { if let Some(ref h) = handler { - h.call(serde_json::json!({"default_view_mode": e.value()})); + h.call(serde_json::json!({ "default_view_mode" : e.value() })); } } }, @@ -487,7 +508,9 @@ pub fn Settings( label { class: "form-label", "Auto-play Media" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "Automatically start playback when opening audio or video." } + span { class: "tooltip-text", + "Automatically start playback when opening audio or video." + } } } { @@ -498,11 +521,10 @@ pub fn Settings( class: "toggle", onclick: move |_| { if let Some(ref h) = handler { - h.call(serde_json::json!({"auto_play_media": !autoplay})); + h.call(serde_json::json!({ "auto_play_media" : ! autoplay })); } }, - div { - class: if autoplay { "toggle-track active" } else { "toggle-track" }, + div { class: if autoplay { "toggle-track active" } else { "toggle-track" }, div { class: "toggle-thumb" } } } @@ -516,7 +538,9 @@ pub fn Settings( label { class: "form-label", "Show Thumbnails" } span { class: "tooltip-trigger", "?" - span { class: "tooltip-text", "Display thumbnail previews in library and search views." } + span { class: "tooltip-text", + "Display thumbnail previews in library and search views." + } } } { @@ -527,11 +551,10 @@ pub fn Settings( class: "toggle", onclick: move |_| { if let Some(ref h) = handler { - h.call(serde_json::json!({"show_thumbnails": !show_thumbs})); + h.call(serde_json::json!({ "show_thumbnails" : ! show_thumbs })); } }, - div { - class: if show_thumbs { "toggle-track active" } else { "toggle-track" }, + div { class: if show_thumbs { "toggle-track active" } else { "toggle-track" }, div { class: "toggle-thumb" } } } diff --git a/crates/pinakes-ui/src/components/statistics.rs b/crates/pinakes-ui/src/components/statistics.rs index 9f2130e..b3f9b9c 100644 --- a/crates/pinakes-ui/src/components/statistics.rs +++ b/crates/pinakes-ui/src/components/statistics.rs @@ -64,7 +64,16 @@ pub fn Statistics( } } - // Media by Type + // Media by Type + + + // Storage by Type + + // Top Tags + + // Top Collections + + // Date Range if !s.media_by_type.is_empty() { div { class: "card mt-16", h4 { class: "card-title", "Media by Type" } @@ -87,7 +96,6 @@ pub fn Statistics( } } - // Storage by Type if !s.storage_by_type.is_empty() { div { class: "card mt-16", h4 { class: "card-title", "Storage by Type" } @@ -110,7 +118,6 @@ pub fn Statistics( } } - // Top Tags if !s.top_tags.is_empty() { div { class: "card mt-16", h4 { class: "card-title", "Top Tags" } @@ -133,7 +140,6 @@ pub fn Statistics( } } - // Top Collections if !s.top_collections.is_empty() { div { class: "card mt-16", h4 { class: "card-title", "Top Collections" } @@ -156,7 +162,6 @@ pub fn Statistics( } } - // Date Range div { class: "card mt-16", h4 { class: "card-title", "Date Range" } div { class: "stats-grid", @@ -171,7 +176,7 @@ pub fn Statistics( } } } - }, + } None => rsx! { div { class: "empty-state", p { "Loading statistics..." } diff --git a/crates/pinakes-ui/src/components/tags.rs b/crates/pinakes-ui/src/components/tags.rs index cecbb6e..9b0c6b8 100644 --- a/crates/pinakes-ui/src/components/tags.rs +++ b/crates/pinakes-ui/src/components/tags.rs @@ -65,18 +65,10 @@ pub fn Tags( onchange: move |e| parent_tag.set(e.value()), option { value: "", "No Parent" } for tag in tags.iter() { - option { - key: "{tag.id}", - value: "{tag.id}", - "{tag.name}" - } + option { key: "{tag.id}", value: "{tag.id}", "{tag.name}" } } } - button { - class: "btn btn-primary", - onclick: create_click, - "Create" - } + button { class: "btn btn-primary", onclick: create_click, "Create" } } if tags.is_empty() { @@ -143,17 +135,19 @@ pub fn Tags( } } if !children.is_empty() { - div { class: "tag-children", style: "margin-left: 16px; margin-top: 4px;", + div { + + class: "tag-children", + style: "margin-left: 16px; margin-top: 4px;", for child in children.iter() { { let child_id = child.id.clone(); let child_name = child.name.clone(); - let child_is_confirming = confirm_delete.read().as_deref() == Some(child_id.as_str()); + let child_is_confirming = confirm_delete.read().as_deref() + == Some(child_id.as_str()); rsx! { - span { - key: "{child_id}", - class: "tag-badge", + span { key: "{child_id}", class: "tag-badge", "{child_name}" if child_is_confirming { { @@ -208,14 +202,21 @@ pub fn Tags( // Orphan child tags (parent not found in current list) for tag in child_tags.iter() { { - let parent_exists = root_tags.iter().any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref()) - || child_tags.iter().any(|c| c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref()); + let parent_exists = root_tags + .iter() + + .any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref()) + || child_tags + .iter() + .any(|c| { + c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref() + }); if !parent_exists { let orphan_id = tag.id.clone(); let orphan_name = tag.name.clone(); let parent_label = tag.parent_id.clone().unwrap_or_default(); - let is_confirming = confirm_delete.read().as_deref() == Some(orphan_id.as_str()); - + let is_confirming = confirm_delete.read().as_deref() + == Some(orphan_id.as_str()); rsx! { span { key: "{orphan_id}", class: "tag-badge", "{orphan_name}" diff --git a/crates/pinakes-ui/src/components/tasks.rs b/crates/pinakes-ui/src/components/tasks.rs index e4eb51c..6f5c902 100644 --- a/crates/pinakes-ui/src/components/tasks.rs +++ b/crates/pinakes-ui/src/components/tasks.rs @@ -75,7 +75,11 @@ pub fn Tasks( button { class: "btn btn-sm btn-secondary mr-8", onclick: move |_| on_toggle.call(task_id_toggle.clone()), - if task.enabled { "Disable" } else { "Enable" } + if task.enabled { + "Disable" + } else { + "Enable" + } } button { class: "btn btn-sm btn-primary", diff --git a/crates/pinakes-ui/src/styles.rs b/crates/pinakes-ui/src/styles.rs index 89bf615..7617baa 100644 --- a/crates/pinakes-ui/src/styles.rs +++ b/crates/pinakes-ui/src/styles.rs @@ -63,17 +63,20 @@ body { .sidebar { width: 220px; min-width: 220px; + max-width: 220px; background: var(--bg-1); border-right: 1px solid var(--border); display: flex; flex-direction: column; + flex-shrink: 0; user-select: none; overflow-y: auto; + overflow-x: hidden; z-index: 10; - transition: width 0.15s, min-width 0.15s; + transition: width 0.15s, min-width 0.15s, max-width 0.15s; } -.sidebar.collapsed { width: 48px; min-width: 48px; } +.sidebar.collapsed { width: 48px; min-width: 48px; max-width: 48px; } .sidebar.collapsed .nav-label, .sidebar.collapsed .sidebar-header .logo, .sidebar.collapsed .sidebar-header .version, @@ -83,9 +86,8 @@ body { /* Nav item text - hide when collapsed */ .nav-item-text { + flex: 1; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .sidebar.collapsed .nav-item-text { display: none; }