diff --git a/crates/pinakes-ui/src/components/detail.rs b/crates/pinakes-ui/src/components/detail.rs index 3bd7003..2e7474f 100644 --- a/crates/pinakes-ui/src/components/detail.rs +++ b/crates/pinakes-ui/src/components/detail.rs @@ -11,10 +11,14 @@ use super::{ use crate::client::{ ApiClient, BookMetadataResponse, + CommentResponse, MediaResponse, MediaUpdateEvent, + RatingResponse, ReadingProgressResponse, + SubtitleListResponse, TagResponse, + TranscodeSessionResponse, }; #[component] @@ -48,6 +52,8 @@ pub fn Detail( #[props(default)] on_update_reading_progress: Option< EventHandler<(String, i32)>, >, + #[props(default)] subtitle_data: Option, + #[props(default)] transcode_sessions: Option>, ) -> Element { let mut editing = use_signal(|| false); let mut show_image_viewer = use_signal(|| false); @@ -66,6 +72,36 @@ pub fn Detail( let mut confirm_delete = use_signal(|| false); + // Enrichment state + let mut enriching = use_signal(|| false); + let mut enrich_done = use_signal(|| false); + let mut enrich_error: Signal> = use_signal(|| None); + let mut show_ext_meta = use_signal(|| false); + let mut ext_meta: Signal> = use_signal(|| None); + + // Subtitle state + let mut subtitle_list: Signal> = + use_signal(|| subtitle_data.clone()); + let mut subtitle_error: Signal> = use_signal(|| None); + + // Transcode state + let mut transcode_list: Signal> = + use_signal(|| transcode_sessions.clone().unwrap_or_default()); + let mut transcode_profile = use_signal(String::new); + // Counter tracks how many start-transcode API calls are in-flight so that + // each profile's button state is independent of others. + let mut transcode_running = use_signal(|| 0u32); + let mut transcode_error: Signal> = use_signal(|| None); + + // Social state + let mut ratings: Signal> = use_signal(Vec::new); + let mut comments: Signal> = use_signal(Vec::new); + let mut is_favorite = use_signal(|| false); + let mut social_loaded = use_signal(|| false); + let mut new_comment_text = use_signal(String::new); + let mut selected_stars = use_signal(|| 0u8); + let mut social_error: Signal> = use_signal(|| None); + let id = media.id.clone(); let title = media.title.clone().unwrap_or_default(); let artist = media.artist.clone().unwrap_or_default(); @@ -971,6 +1007,520 @@ pub fn Detail( } } + // Social section: ratings, comments, favorites + { + let social_id = id.clone(); + let client_social = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Social" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + let mut errors: Vec = Vec::new(); + match cs.get_ratings(&sid).await { + Ok(r) => ratings.set(r), + Err(e) => errors.push(format!("ratings: {e}")), + } + match cs.list_comments(&sid).await { + Ok(c) => comments.set(c), + Err(e) => errors.push(format!("comments: {e}")), + } + match cs.list_favorites().await { + Ok(favs) => is_favorite.set(favs.iter().any(|m| m.id == sid)), + Err(e) => errors.push(format!("favorites: {e}")), + } + social_loaded.set(true); + if errors.is_empty() { + social_error.set(None); + } else { + social_error.set(Some(errors.join("; "))); + } + }); + } + }, + "Load" + } + } + if *social_loaded.read() { + div { class: "card-body", + if let Some(ref err) = *social_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + // Favorites + div { class: "form-row mb-8", + if *is_favorite.read() { + button { + class: "btn btn-secondary btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.remove_favorite(&sid).await { + Ok(_) => is_favorite.set(false), + Err(e) => social_error.set(Some(format!("Failed: {e}"))), + } + }); + } + }, + "\u{2665} Unfavorite" + } + } else { + button { + class: "btn btn-primary btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.add_favorite(&sid).await { + Ok(_) => is_favorite.set(true), + Err(e) => social_error.set(Some(format!("Failed: {e}"))), + } + }); + } + }, + "\u{2661} Favorite" + } + } + } + // Rating + div { class: "form-row mb-8", + span { class: "detail-label", "Rate: " } + for star in 1u8..=5u8 { + { + let cs = client_social.clone(); + let sid = social_id.clone(); + rsx! { + button { + key: "{star}", + class: if *selected_stars.read() >= star { "btn btn-sm star-btn active" } else { "btn btn-sm star-btn" }, + onclick: move |_| { + let cs = cs.clone(); + let sid = sid.clone(); + selected_stars.set(star); + spawn(async move { + if let Err(e) = cs.rate_media(&sid, star).await { + social_error.set(Some(format!("Rating failed: {e}"))); + } else if let Ok(r) = cs.get_ratings(&sid).await { + ratings.set(r); + } + }); + }, + "\u{2605}" + } + } + } + } + span { class: "text-muted", "({ratings.read().len()} ratings)" } + } + // Comments + div { class: "mb-8", + h5 { "Comments" } + if comments.read().is_empty() { + p { class: "text-muted text-sm", "No comments." } + } else { + div { class: "comments-list", + for comment in comments.read().iter() { + div { class: "comment-item", key: "{comment.id}", + span { class: "comment-text", "{comment.text}" } + if let Some(ref t) = comment.created_at { + span { class: "text-muted text-sm", " - {t}" } + } + } + } + } + } + div { class: "form-row mt-8", + input { + r#type: "text", + placeholder: "Add a comment...", + value: "{new_comment_text}", + oninput: move |e| new_comment_text.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let sid = social_id.clone(); + let cs = client_social.clone(); + move |_| { + let text = new_comment_text.read().clone(); + if text.is_empty() { + return; + } + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.add_comment(&sid, &text).await { + Ok(c) => { + comments.write().push(c); + new_comment_text.set(String::new()); + } + Err(e) => social_error.set(Some(format!("Comment failed: {e}"))), + } + }); + } + }, + "Post" + } + } + } + } + } + } + } + } + + // Enrichment section (all media types) + { + let enrich_id = id.clone(); + let client_enrich = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Metadata Enrichment" } + } + div { class: "card-body", + if let Some(ref err) = *enrich_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + div { class: "form-row", + button { + class: "btn btn-primary btn-sm", + disabled: *enriching.read(), + onclick: { + let eid = enrich_id.clone(); + let ce = client_enrich.clone(); + move |_| { + let eid = eid.clone(); + let ce = ce.clone(); + spawn(async move { + enriching.set(true); + enrich_done.set(false); + enrich_error.set(None); + match ce.enrich_media(&eid).await { + Ok(_) => enrich_done.set(true), + Err(e) => enrich_error.set(Some(format!("Enrichment failed: {e}"))), + } + enriching.set(false); + }); + } + }, + if *enriching.read() { "Enriching..." } else { "Enrich Metadata" } + } + if *enrich_done.read() { + span { class: "badge badge-success", "Enrichment complete" } + } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let eid = enrich_id.clone(); + let ce = client_enrich.clone(); + move |_| { + let eid = eid.clone(); + let ce = ce.clone(); + show_ext_meta.toggle(); + if *show_ext_meta.read() && ext_meta.read().is_none() { + spawn(async move { + match ce.get_external_metadata(&eid).await { + Ok(v) => ext_meta.set(Some(v)), + Err(e) => enrich_error.set(Some(format!("Metadata fetch failed: {e}"))), + } + }); + } + } + }, + if *show_ext_meta.read() { "Hide External Metadata" } else { "Show External Metadata" } + } + } + if *show_ext_meta.read() { + if let Some(ref meta) = *ext_meta.read() { + pre { class: "mono text-sm", "{meta}" } + } else { + p { class: "text-muted", "Loading external metadata..." } + } + } + } + } + } + } + + // Subtitles section (video and audio only) + if category == "video" || category == "audio" { + { + let sub_id = id.clone(); + let client_sub = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Subtitles" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let sid = sub_id.clone(); + let cs = client_sub.clone(); + move |_| { + let sid = sid.clone(); + let cs = cs.clone(); + spawn(async move { + match cs.list_subtitles_for_media(&sid).await { + Ok(data) => { + subtitle_error.set(None); + subtitle_list.set(Some(data)); + } + Err(e) => subtitle_error.set(Some(format!("Failed to load subtitles: {e}"))), + } + }); + } + }, + "Refresh" + } + } + if let Some(ref err) = *subtitle_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + if let Some(ref subs) = *subtitle_list.read() { + if !subs.available_tracks.is_empty() { + div { class: "mb-8", + h5 { "Embedded Tracks" } + table { class: "data-table", + thead { + tr { + th { "Index" } + th { "Language" } + th { "Format" } + th { "Title" } + } + } + tbody { + for track in subs.available_tracks.iter() { + tr { key: "{track.index}", + td { "{track.index}" } + td { "{track.language.clone().unwrap_or_default()}" } + td { "{track.format}" } + td { "{track.title.clone().unwrap_or_default()}" } + } + } + } + } + } + } + if subs.subtitles.is_empty() { + p { class: "text-muted", "No external subtitles." } + } else { + table { class: "data-table", + thead { + tr { + th { "Language" } + th { "Format" } + th { "Embedded" } + th { "Offset (ms)" } + th { "" } + } + } + tbody { + for sub in subs.subtitles.iter() { + { + let sub_entry_id = sub.id.clone(); + let cs2 = client_sub.clone(); + let sid2 = sub_id.clone(); + rsx! { + tr { key: "{sub_entry_id}", + td { "{sub.language.clone().unwrap_or_default()}" } + td { "{sub.format}" } + td { if sub.is_embedded { "Yes" } else { "No" } } + td { "{sub.offset_ms}" } + td { + button { + class: "btn btn-danger btn-sm", + onclick: move |_| { + let eid = sub_entry_id.clone(); + let cs2 = cs2.clone(); + let sid2 = sid2.clone(); + spawn(async move { + match cs2.delete_subtitle(&eid).await { + Ok(()) => { + match cs2.list_subtitles_for_media(&sid2).await { + Ok(data) => { + subtitle_error.set(None); + subtitle_list.set(Some(data)); + } + Err(e) => subtitle_error.set(Some(format!("Failed to refresh subtitles: {e}"))), + } + } + Err(e) => subtitle_error.set(Some(format!("Failed to delete subtitle: {e}"))), + } + }); + }, + "Delete" + } + } + } + } + } + } + } + } + } + } else { + p { class: "text-muted", + "Click Refresh to load subtitle information." + } + } + } + } + } + } + + // Transcode section (video and audio only) + if category == "video" || category == "audio" { + { + let tc_id = id.clone(); + let client_tc = client.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Transcoding" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let cs = client_tc.clone(); + let tid = tc_id.clone(); + move |_| { + let cs = cs.clone(); + let tid = tid.clone(); + spawn(async move { + if let Ok(all) = cs.list_transcodes().await { + let filtered: Vec<_> = all + .into_iter() + .filter(|s| s.media_id == tid) + .collect(); + transcode_list.set(filtered); + } + }); + } + }, + "Refresh" + } + } + if let Some(ref err) = *transcode_error.read() { + div { class: "error-banner mb-8", "{err}" } + } + div { class: "form-row mb-8", + input { + r#type: "text", + placeholder: "Profile (e.g. web-720p)...", + value: "{transcode_profile}", + oninput: move |e| transcode_profile.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + disabled: *transcode_running.read() > 0, + onclick: { + let tid = tc_id.clone(); + let ct = client_tc.clone(); + move |_| { + let profile = transcode_profile.read().clone(); + if profile.is_empty() { + return; + } + let tid = tid.clone(); + let ct = ct.clone(); + spawn(async move { + *transcode_running.write() += 1; + transcode_error.set(None); + match ct.start_transcode(&tid, &profile).await { + Ok(session) => { + transcode_list.write().push(session); + transcode_profile.set(String::new()); + } + Err(e) => transcode_error.set(Some(format!("Transcode failed: {e}"))), + } + let prev = *transcode_running.read(); + *transcode_running.write() = prev.saturating_sub(1); + }); + } + }, + if *transcode_running.read() > 0 { "Starting..." } else { "Start Transcode" } + } + } + if transcode_list.read().is_empty() { + p { class: "text-muted", "No transcode sessions for this item." } + } else { + table { class: "data-table", + thead { + tr { + th { "Profile" } + th { "Status" } + th { "Progress" } + th { "" } + } + } + tbody { + for session in transcode_list.read().clone() { + { + let sess_id = session.id.clone(); + let ct2 = client_tc.clone(); + let is_active = session.status == "running" || session.status == "pending"; + let progress = session + .progress + .map(|p| format!("{:.0}%", p)) + .unwrap_or_default(); + rsx! { + tr { key: "{sess_id}", + td { "{session.profile}" } + td { + span { + class: if is_active { "badge badge-warning" } else { "badge badge-neutral" }, + "{session.status}" + } + } + td { "{progress}" } + td { + if is_active { + button { + class: "btn btn-danger btn-sm", + onclick: move |_| { + let sid = sess_id.clone(); + let ct2 = ct2.clone(); + spawn(async move { + match ct2.cancel_transcode(&sid).await { + Ok(()) => { + transcode_error.set(None); + transcode_list.write().retain(|s| s.id != sid); + } + Err(e) => transcode_error.set(Some(format!("Cancel failed: {e}"))), + } + }); + }, + "Cancel" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + // Image viewer overlay if *show_image_viewer.read() { ImageViewer { diff --git a/crates/pinakes-ui/src/components/media_player.rs b/crates/pinakes-ui/src/components/media_player.rs index 73d0e01..5c84fca 100644 --- a/crates/pinakes-ui/src/components/media_player.rs +++ b/crates/pinakes-ui/src/components/media_player.rs @@ -1,4 +1,7 @@ -use dioxus::{document::eval, prelude::*}; +use dioxus::{ + document::{Script, eval}, + prelude::*, +}; use super::utils::format_duration; @@ -123,6 +126,43 @@ impl PlayQueue { } } +/// Generate JavaScript to initialize hls.js for an HLS stream URL. +/// +/// Destroys any previously created instance stored at `window.__hlsInstances` +/// keyed by element ID before creating a new one, preventing memory leaks and +/// multiple instances competing for the same video element across re-renders. +fn hls_init_script(video_id: &str, hls_url: &str) -> String { + // JSON-encode both values so embedded quotes/backslashes cannot break out of + // the JS string. + let encoded_url = + serde_json::to_string(hls_url).unwrap_or_else(|_| "\"\"".to_string()); + let encoded_id = + serde_json::to_string(video_id).unwrap_or_else(|_| "\"\"".to_string()); + format!( + r#" + (function() {{ + window.__hlsInstances = window.__hlsInstances || {{}}; + var existing = window.__hlsInstances[{encoded_id}]; + if (existing) {{ + existing.destroy(); + window.__hlsInstances[{encoded_id}] = null; + }} + if (typeof Hls !== 'undefined' && Hls.isSupported()) {{ + var hls = new Hls(); + hls.loadSource({encoded_url}); + hls.attachMedia(document.getElementById({encoded_id})); + window.__hlsInstances[{encoded_id}] = hls; + }} else {{ + var video = document.getElementById({encoded_id}); + if (video && video.canPlayType('application/vnd.apple.mpegurl')) {{ + video.src = {encoded_url}; + }} + }} + }})(); + "# + ) +} + #[component] pub fn MediaPlayer( src: String, @@ -200,6 +240,35 @@ pub fn MediaPlayer( }); }); + // HLS initialization for .m3u8 streams. + // use_effect must be called unconditionally to maintain stable hook ordering. + let is_hls = src.ends_with(".m3u8"); + let hls_src = src.clone(); + use_effect(move || { + if !hls_src.ends_with(".m3u8") { + return; + } + let js = hls_init_script("pinakes-player", &hls_src); + spawn(async move { + // Poll until hls.js is loaded rather than using a fixed delay, so we + // initialize as soon as the script is ready without timing out on slow + // connections. Max wait: 25 * 100ms = 2.5s. + const MAX_POLLS: u32 = 25; + for _ in 0..MAX_POLLS { + if let Ok(val) = eval("typeof Hls !== 'undefined'").await { + if val == serde_json::Value::Bool(true) { + let _ = eval(&js).await; + return; + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + tracing::warn!( + "hls.js did not load within 2.5s; HLS stream will not play" + ); + }); + }); + // Autoplay on mount if autoplay { let src_auto = src.clone(); @@ -367,24 +436,31 @@ pub fn MediaPlayer( let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; rsx! { + // Load hls.js for HLS stream support when needed + if is_hls { + // Pin to a specific release so unexpected upstream changes cannot + // break playback. Update this when intentionally upgrading hls.js. + Script { src: "https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js" } + } + div { class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, tabindex: "0", onkeydown: on_keydown, - // Hidden native element + // Hidden native element; for HLS streams skip the src attr (hls.js attaches it) if is_video { video { id: "pinakes-player", - src: "{src}", - style: if is_video { "width: 100%; display: block;" } else { "display: none;" }, + class: "player-native-video", + src: if is_hls { String::new() } else { src.clone() }, preload: "metadata", } } else { audio { id: "pinakes-player", - src: "{src}", - style: "display: none;", + class: "player-native-audio", + src: if is_hls { String::new() } else { src.clone() }, preload: "metadata", } } diff --git a/crates/pinakes-ui/src/components/mod.rs b/crates/pinakes-ui/src/components/mod.rs index 605a528..57b5730 100644 --- a/crates/pinakes-ui/src/components/mod.rs +++ b/crates/pinakes-ui/src/components/mod.rs @@ -16,6 +16,7 @@ pub mod markdown_viewer; pub mod media_player; pub mod pagination; pub mod pdf_viewer; +pub mod playlists; pub mod search; pub mod settings; pub mod statistics; diff --git a/crates/pinakes-ui/src/components/playlists.rs b/crates/pinakes-ui/src/components/playlists.rs new file mode 100644 index 0000000..c821d6d --- /dev/null +++ b/crates/pinakes-ui/src/components/playlists.rs @@ -0,0 +1,411 @@ +use dioxus::prelude::*; + +use super::utils::{format_size, type_badge_class}; +use crate::client::{ApiClient, MediaResponse, PlaylistResponse}; + +#[component] +pub fn Playlists(client: Signal) -> Element { + let mut playlists: Signal> = use_signal(Vec::new); + let mut items: Signal> = use_signal(Vec::new); + let mut selected_id: Signal> = use_signal(|| None); + let mut loading = use_signal(|| false); + let mut error: Signal> = use_signal(|| None); + + let mut new_name = use_signal(String::new); + let mut new_desc = use_signal(String::new); + let mut confirm_delete: Signal> = use_signal(|| None); + let mut renaming_id: Signal> = use_signal(|| None); + let mut rename_input = use_signal(String::new); + let mut add_media_id = use_signal(String::new); + + // Load playlists on mount + use_effect(move || { + spawn(async move { + loading.set(true); + match client.read().list_playlists().await { + Ok(list) => playlists.set(list), + Err(e) => error.set(Some(format!("Failed to load playlists: {e}"))), + } + loading.set(false); + }); + }); + + let load_items = move |pid: String| { + spawn(async move { + match client.read().get_playlist_items(&pid).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Failed to load items: {e}"))), + } + }); + }; + + let on_create = move |_| { + let name = new_name.read().clone(); + if name.is_empty() { + return; + } + let desc = { + let d = new_desc.read().clone(); + if d.is_empty() { None } else { Some(d) } + }; + spawn(async move { + match client.read().create_playlist(&name, desc.as_deref()).await { + Ok(pl) => { + playlists.write().push(pl); + new_name.set(String::new()); + new_desc.set(String::new()); + }, + Err(e) => error.set(Some(format!("Failed to create playlist: {e}"))), + } + }); + }; + + let on_shuffle = move |pid: String| { + spawn(async move { + match client.read().shuffle_playlist(&pid).await { + Ok(_) => { + // Reload items if this is the selected playlist + if selected_id.read().as_deref() == Some(&pid) { + match client.read().get_playlist_items(&pid).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Failed to reload: {e}"))), + } + } + }, + Err(e) => error.set(Some(format!("Shuffle failed: {e}"))), + } + }); + }; + + // Detail view: show items for selected playlist + if let Some(ref pid) = selected_id.read().clone() { + let pl_name = playlists + .read() + .iter() + .find(|p| &p.id == pid) + .map(|p| p.name.clone()) + .unwrap_or_else(|| pid.clone()); + + let pid_for_shuffle = pid.clone(); + let pid_for_back = pid.clone(); + + return rsx! { + div { class: "form-row mb-16", + button { + class: "btn btn-ghost", + onclick: move |_| { + let _ = &pid_for_back; + selected_id.set(None); + items.set(Vec::new()); + }, + "\u{2190} Back to Playlists" + } + } + + h3 { class: "mb-16", "{pl_name}" } + + if let Some(ref err) = *error.read() { + div { class: "error-banner", "{err}" } + } + + div { class: "form-row mb-16", + button { + class: "btn btn-secondary btn-sm", + onclick: move |_| on_shuffle(pid_for_shuffle.clone()), + "\u{1f500} Shuffle" + } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Media ID to add...", + value: "{add_media_id}", + oninput: move |e| add_media_id.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let pid = pid.clone(); + move |_| { + let mid = add_media_id.read().clone(); + if mid.is_empty() { + return; + } + let pid = pid.clone(); + spawn(async move { + match client.read().add_to_playlist(&pid, &mid).await { + Ok(_) => { + add_media_id.set(String::new()); + match client.read().get_playlist_items(&pid).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Reload failed: {e}"))), + } + } + Err(e) => error.set(Some(format!("Add failed: {e}"))), + } + }); + } + }, + "Add Media" + } + } + + if *loading.read() { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading..." + } + } else if items.read().is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No items in this playlist." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } + th { "Size" } + th { "" } + } + } + tbody { + for item in items.read().clone() { + { + let pid_rm = pid.clone(); + let mid = item.id.clone(); + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + rsx! { + tr { key: "{mid}", + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + td { + button { + class: "btn btn-danger btn-sm", + onclick: move |_| { + let pid_rm = pid_rm.clone(); + let mid = mid.clone(); + spawn(async move { + match client.read().remove_from_playlist(&pid_rm, &mid).await { + Ok(_) => { + match client.read().get_playlist_items(&pid_rm).await { + Ok(list) => items.set(list), + Err(e) => error.set(Some(format!("Reload failed: {e}"))), + } + } + Err(e) => error.set(Some(format!("Remove failed: {e}"))), + } + }); + }, + "Remove" + } + } + } + } + } + } + } + } + } + }; + } + + // List view + rsx! { + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Playlists" } + } + + if let Some(ref err) = *error.read() { + div { class: "error-banner mb-16", "{err}" } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Playlist name...", + value: "{new_name}", + oninput: move |e| new_name.set(e.value()), + } + input { + r#type: "text", + placeholder: "Description (optional)...", + value: "{new_desc}", + oninput: move |e| new_desc.set(e.value()), + } + button { class: "btn btn-primary", onclick: on_create, "Create" } + } + + if *loading.read() { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading..." + } + } else if playlists.read().is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No playlists yet. Create one above." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Description" } + th { "Items" } + th { "" } + th { "" } + th { "" } + } + } + tbody { + for pl in playlists.read().clone() { + { + let pl_id = pl.id.clone(); + let pl_id_open = pl.id.clone(); + let pl_id_rename = pl.id.clone(); + let desc = pl.description.clone().unwrap_or_default(); + let count = pl.item_count.map(|c| c.to_string()).unwrap_or_default(); + let is_confirming = confirm_delete + .read() + .as_ref() + .map(|id| id == &pl.id) + .unwrap_or(false); + let is_renaming = renaming_id + .read() + .as_ref() + .map(|id| id == &pl.id) + .unwrap_or(false); + rsx! { + tr { key: "{pl_id}", + td { + if is_renaming { + div { class: "form-row", + input { + r#type: "text", + value: "{rename_input}", + oninput: move |e| rename_input.set(e.value()), + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let rid = pl_id_rename.clone(); + move |_| { + let rid = rid.clone(); + let new_name_val = rename_input.read().clone(); + if new_name_val.is_empty() { + return; + } + spawn(async move { + match client.read().update_playlist(&rid, &new_name_val).await { + Ok(updated) => { + let mut list = playlists.write(); + if let Some(p) = list.iter_mut().find(|p| p.id == rid) { + *p = updated; + } + renaming_id.set(None); + } + Err(e) => error.set(Some(format!("Rename failed: {e}"))), + } + }); + } + }, + "Save" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| renaming_id.set(None), + "Cancel" + } + } + } else { + "{pl.name}" + } + } + td { "{desc}" } + td { "{count}" } + td { + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| { + let pid = pl_id_open.clone(); + selected_id.set(Some(pid.clone())); + load_items(pid); + }, + "Open" + } + } + td { + if !is_renaming { + button { + class: "btn btn-sm btn-ghost", + onclick: { + let rid = pl_id.clone(); + let rname = pl.name.clone(); + move |_| { + rename_input.set(rname.clone()); + renaming_id.set(Some(rid.clone())); + } + }, + "Rename" + } + } + } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = pl_id.clone(); + move |_| { + let id = id.clone(); + spawn(async move { + match client.read().delete_playlist(&id).await { + Ok(_) => { + playlists.write().retain(|p| p.id != id); + confirm_delete.set(None); + } + Err(e) => { + error.set(Some(format!("Delete failed: {e}"))); + confirm_delete.set(None); + } + } + }); + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = pl_id.clone(); + move |_| confirm_delete.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/settings.rs b/crates/pinakes-ui/src/components/settings.rs index 6666270..c780141 100644 --- a/crates/pinakes-ui/src/components/settings.rs +++ b/crates/pinakes-ui/src/components/settings.rs @@ -1,6 +1,6 @@ use dioxus::prelude::*; -use crate::client::ConfigResponse; +use crate::client::{ApiClient, ConfigResponse}; #[component] pub fn Settings( @@ -13,6 +13,8 @@ pub fn Settings( #[props(default)] on_update_ui_config: Option< EventHandler, >, + #[props(default)] client: Option, + #[props(default)] current_user_role: Option, ) -> Element { let mut new_root = use_signal(String::new); let mut editing_poll = use_signal(|| false); @@ -21,13 +23,475 @@ pub fn Settings( let mut editing_patterns = use_signal(|| false); let mut patterns_input = use_signal(String::new); + // Tab state: "general", "users", "sync", "webhooks" + let mut active_tab = use_signal(|| "general".to_string()); + + // Users tab state + let mut users_list = use_signal(Vec::::new); + let mut users_loaded = use_signal(|| false); + let mut users_loading = use_signal(|| false); + let mut users_error: Signal> = use_signal(|| None); + let mut new_username = use_signal(String::new); + let mut new_password = use_signal(String::new); + let mut new_role = use_signal(|| "viewer".to_string()); + let mut confirm_delete_user: Signal> = use_signal(|| None); + + // Sync devices tab state + let mut devices_list = use_signal(Vec::::new); + let mut devices_loaded = use_signal(|| false); + let mut devices_loading = use_signal(|| false); + let mut devices_error: Signal> = use_signal(|| None); + let mut confirm_delete_device: Signal> = use_signal(|| None); + + // Webhooks tab state + let mut webhooks_list = + use_signal(Vec::::new); + let mut webhooks_loaded = use_signal(|| false); + let mut webhooks_loading = use_signal(|| false); + let mut webhooks_error: Signal> = use_signal(|| None); + let writable = config.config_writable; let watch_enabled = config.scanning.watch; let host_port = format!("{}:{}", config.server.host, config.server.port); let db_path = config.database_path.clone().unwrap_or_default(); let root_count = config.roots.len(); + let is_admin = current_user_role + .as_deref() + .map(|r| r == "admin") + .unwrap_or(false); + rsx! { + // Tab bar + div { class: "tab-bar mb-16", + button { + class: if *active_tab.read() == "general" { "tab-btn active" } else { "tab-btn" }, + onclick: move |_| active_tab.set("general".to_string()), + "General" + } + if is_admin { + button { + class: if *active_tab.read() == "users" { "tab-btn active" } else { "tab-btn" }, + onclick: { + let c = client.clone(); + move |_| { + active_tab.set("users".to_string()); + if !*users_loaded.read() { + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + users_loading.set(true); + match api.list_users().await { + Ok(list) => { users_list.set(list); users_loaded.set(true); } + Err(e) => users_error.set(Some(format!("Failed to load users: {e}"))), + } + users_loading.set(false); + }); + } + } + } + }, + "Users" + } + } + if is_admin { + button { + class: if *active_tab.read() == "sync" { "tab-btn active" } else { "tab-btn" }, + onclick: { + let c = client.clone(); + move |_| { + active_tab.set("sync".to_string()); + if !*devices_loaded.read() && !*devices_loading.read() { + if let Some(ref api) = c { + let api = api.clone(); + devices_loading.set(true); + spawn(async move { + match api.list_sync_devices().await { + Ok(list) => { devices_list.set(list); devices_loaded.set(true); } + Err(e) => devices_error.set(Some(format!("Failed to load devices: {e}"))), + } + devices_loading.set(false); + }); + } + } + } + }, + "Sync Devices" + } + } + if is_admin { + button { + class: if *active_tab.read() == "webhooks" { "tab-btn active" } else { "tab-btn" }, + onclick: { + let c = client.clone(); + move |_| { + active_tab.set("webhooks".to_string()); + if !*webhooks_loaded.read() && !*webhooks_loading.read() { + if let Some(ref api) = c { + let api = api.clone(); + webhooks_loading.set(true); + spawn(async move { + match api.list_webhooks().await { + Ok(list) => { webhooks_list.set(list); webhooks_loaded.set(true); } + Err(e) => webhooks_error.set(Some(format!("Failed to load webhooks: {e}"))), + } + webhooks_loading.set(false); + }); + } + } + } + }, + "Webhooks" + } + } + } + + if *active_tab.read() == "users" { + div { class: "settings-layout", + if let Some(ref err) = *users_error.read() { + div { class: "error-banner mb-16", "{err}" } + } + if is_admin { + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Create User" } + } + div { class: "settings-card-body", + div { class: "form-row mb-8", + input { + r#type: "text", + placeholder: "Username...", + value: "{new_username}", + oninput: move |e| new_username.set(e.value()), + } + input { + r#type: "password", + placeholder: "Password...", + value: "{new_password}", + oninput: move |e| new_password.set(e.value()), + } + select { + value: "{new_role}", + onchange: move |e| new_role.set(e.value()), + option { value: "viewer", "Viewer" } + option { value: "editor", "Editor" } + option { value: "admin", "Admin" } + } + button { + class: "btn btn-primary btn-sm", + onclick: { + let c = client.clone(); + move |_| { + let username = new_username.read().clone(); + let password = new_password.read().clone(); + let role = new_role.read().clone(); + if username.is_empty() || password.is_empty() { + users_error.set(Some("Username and password are required.".to_string())); + return; + } + users_error.set(None); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.create_user(&username, &password, &role).await { + Ok(u) => { + users_list.write().push(u); + new_username.set(String::new()); + new_password.set(String::new()); + } + Err(e) => users_error.set(Some(format!("Create failed: {e}"))), + } + }); + } + } + }, + "Create" + } + } + } + } + } + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Users" } + } + div { class: "settings-card-body", + if *users_loading.read() { + div { class: "spinner" } + } else if users_list.read().is_empty() { + p { class: "text-muted", "No users." } + } else { + table { class: "data-table", + thead { + tr { + th { "Username" } + th { "Role" } + th { "Created" } + if is_admin { + th { "Change Role" } + th { "" } + } + } + } + tbody { + for user in users_list.read().clone() { + { + let uid = user.id.clone(); + let created = user.created_at.clone().unwrap_or_default(); + let current_role = user.role.clone(); + let is_confirming = confirm_delete_user + .read() + .as_ref() + .map(|id| id == &user.id) + .unwrap_or(false); + rsx! { + tr { key: "{uid}", + td { "{user.username}" } + td { + span { class: "role-badge role-{user.role}", "{user.role}" } + } + td { "{created}" } + if is_admin { + td { + select { + value: "{current_role}", + onchange: { + let id = uid.clone(); + let c = client.clone(); + move |e: Event| { + let new_role = e.value(); + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.update_user(&id, &new_role).await { + Ok(updated) => { + users_error.set(None); + let mut list = users_list.write(); + if let Some(u) = list.iter_mut().find(|u| u.id == id) { + *u = updated; + } + } + Err(e) => users_error.set(Some(format!("Failed to update role: {e}"))), + } + }); + } + } + }, + option { value: "viewer", "Viewer" } + option { value: "editor", "Editor" } + option { value: "admin", "Admin" } + } + } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = uid.clone(); + let c = client.clone(); + move |_| { + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + if api.delete_user(&id).await.is_ok() { + users_list.write().retain(|u| u.id != id); + confirm_delete_user.set(None); + } + }); + } + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete_user.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = uid.clone(); + move |_| confirm_delete_user.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } else if *active_tab.read() == "sync" { + div { class: "settings-layout", + if let Some(ref err) = *devices_error.read() { + div { class: "error-banner mb-16", "{err}" } + } + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Sync Devices" } + } + div { class: "settings-card-body", + if *devices_loading.read() { + div { class: "spinner" } + } else if devices_list.read().is_empty() { + p { class: "text-muted", "No sync devices registered." } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Last Seen" } + th { "" } + } + } + tbody { + for device in devices_list.read().clone() { + { + let did = device.id.clone(); + let last_seen = device.last_seen.clone().unwrap_or_default(); + let is_confirming = confirm_delete_device + .read() + .as_ref() + .map(|id| id == &device.id) + .unwrap_or(false); + rsx! { + tr { key: "{did}", + td { "{device.name}" } + td { "{device.device_type}" } + td { "{last_seen}" } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = did.clone(); + let c = client.clone(); + move |_| { + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.delete_sync_device(&id).await { + Ok(()) => { + devices_error.set(None); + devices_list.write().retain(|d| d.id != id); + confirm_delete_device.set(None); + } + Err(e) => { + devices_error.set(Some(format!("Failed to delete device: {e}"))); + confirm_delete_device.set(None); + } + } + }); + } + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete_device.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = did.clone(); + move |_| confirm_delete_device.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } + } + } else if *active_tab.read() == "webhooks" { + div { class: "settings-layout", + if let Some(ref err) = *webhooks_error.read() { + div { class: "error-banner mb-16", "{err}" } + } + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Webhooks" } + } + div { class: "settings-card-body", + if *webhooks_loading.read() { + div { class: "spinner" } + } else if webhooks_list.read().is_empty() { + p { class: "text-muted", "No webhooks configured." } + } else { + table { class: "data-table", + thead { + tr { + th { "URL" } + th { "Events" } + th { "" } + } + } + tbody { + for wh in webhooks_list.read().clone() { + { + let wid = wh.id.clone(); + let events = wh.events.join(", "); + rsx! { + tr { key: "{wid}", + td { class: "mono", "{wh.url}" } + td { "{events}" } + td { + button { + class: "btn btn-secondary btn-sm", + onclick: { + let id = wid.clone(); + let c = client.clone(); + move |_| { + let id = id.clone(); + if let Some(ref api) = c { + let api = api.clone(); + spawn(async move { + match api.test_webhook(&id).await { + Ok(_) => webhooks_error.set(None), + Err(e) => webhooks_error.set(Some(format!("Test failed: {e}"))), + } + }); + } + } + }, + "Test" + } + } + } + } + } + } + } + } + } + } + } + } + } else { + // General tab (original content) div { class: "settings-layout", // Configuration source @@ -567,5 +1031,6 @@ pub fn Settings( } } } + } // end else (general tab) } }