Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ifb9c9da6fec0a9152b54ccf48705088e6a6a6964
411 lines
19 KiB
Rust
411 lines
19 KiB
Rust
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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|