pinakes-ui: add playlists; expand detail/settings/player components
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ifb9c9da6fec0a9152b54ccf48705088e6a6a6964
This commit is contained in:
parent
bb69f2fa37
commit
0feb51d7b4
5 changed files with 1510 additions and 7 deletions
|
|
@ -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<SubtitleListResponse>,
|
||||
#[props(default)] transcode_sessions: Option<Vec<TranscodeSessionResponse>>,
|
||||
) -> 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<Option<String>> = use_signal(|| None);
|
||||
let mut show_ext_meta = use_signal(|| false);
|
||||
let mut ext_meta: Signal<Option<serde_json::Value>> = use_signal(|| None);
|
||||
|
||||
// Subtitle state
|
||||
let mut subtitle_list: Signal<Option<SubtitleListResponse>> =
|
||||
use_signal(|| subtitle_data.clone());
|
||||
let mut subtitle_error: Signal<Option<String>> = use_signal(|| None);
|
||||
|
||||
// Transcode state
|
||||
let mut transcode_list: Signal<Vec<TranscodeSessionResponse>> =
|
||||
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<Option<String>> = use_signal(|| None);
|
||||
|
||||
// Social state
|
||||
let mut ratings: Signal<Vec<RatingResponse>> = use_signal(Vec::new);
|
||||
let mut comments: Signal<Vec<CommentResponse>> = 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<Option<String>> = 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<String> = 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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
411
crates/pinakes-ui/src/components/playlists.rs
Normal file
411
crates/pinakes-ui/src/components/playlists.rs
Normal file
|
|
@ -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<ApiClient>) -> Element {
|
||||
let mut playlists: Signal<Vec<PlaylistResponse>> = use_signal(Vec::new);
|
||||
let mut items: Signal<Vec<MediaResponse>> = use_signal(Vec::new);
|
||||
let mut selected_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut loading = use_signal(|| false);
|
||||
let mut error: Signal<Option<String>> = use_signal(|| None);
|
||||
|
||||
let mut new_name = use_signal(String::new);
|
||||
let mut new_desc = use_signal(String::new);
|
||||
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut renaming_id: Signal<Option<String>> = 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<serde_json::Value>,
|
||||
>,
|
||||
#[props(default)] client: Option<ApiClient>,
|
||||
#[props(default)] current_user_role: Option<String>,
|
||||
) -> 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::<crate::client::UserResponse>::new);
|
||||
let mut users_loaded = use_signal(|| false);
|
||||
let mut users_loading = use_signal(|| false);
|
||||
let mut users_error: Signal<Option<String>> = 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<Option<String>> = use_signal(|| None);
|
||||
|
||||
// Sync devices tab state
|
||||
let mut devices_list = use_signal(Vec::<crate::client::DeviceResponse>::new);
|
||||
let mut devices_loaded = use_signal(|| false);
|
||||
let mut devices_loading = use_signal(|| false);
|
||||
let mut devices_error: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut confirm_delete_device: Signal<Option<String>> = use_signal(|| None);
|
||||
|
||||
// Webhooks tab state
|
||||
let mut webhooks_list =
|
||||
use_signal(Vec::<crate::client::WebhookResponse>::new);
|
||||
let mut webhooks_loaded = use_signal(|| false);
|
||||
let mut webhooks_loading = use_signal(|| false);
|
||||
let mut webhooks_error: Signal<Option<String>> = 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<FormData>| {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue