Added the playlist editor
This commit is contained in:
@@ -14,5 +14,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window"] }
|
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window", "DragEvent", "DataTransfer"] }
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ async fn post_empty<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
|
|||||||
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn put_json<T: DeserializeOwned>(url: &str, body: &str) -> Result<T, ApiError> {
|
||||||
|
let resp = Request::put(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body)
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete(url: &str) -> Result<(), ApiError> {
|
async fn delete(url: &str) -> Result<(), ApiError> {
|
||||||
let resp = Request::delete(url)
|
let resp = Request::delete(url)
|
||||||
.send()
|
.send()
|
||||||
@@ -304,6 +318,36 @@ pub fn export_m3u_url(id: i32) -> String {
|
|||||||
format!("{BASE}/playlists/{id}/m3u")
|
format!("{BASE}/playlists/{id}/m3u")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn add_track_to_playlist(
|
||||||
|
playlist_id: i32,
|
||||||
|
track_id: i32,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let body = serde_json::json!({"track_id": track_id}).to_string();
|
||||||
|
post_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_track_from_playlist(
|
||||||
|
playlist_id: i32,
|
||||||
|
track_id: i32,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
delete(&format!(
|
||||||
|
"{BASE}/playlists/{playlist_id}/tracks/{track_id}"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reorder_playlist_tracks(
|
||||||
|
playlist_id: i32,
|
||||||
|
track_ids: &[i32],
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let body = serde_json::json!({"track_ids": track_ids}).to_string();
|
||||||
|
put_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_tracks(query: &str) -> Result<Vec<Track>, ApiError> {
|
||||||
|
get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await
|
||||||
|
}
|
||||||
|
|
||||||
// --- YouTube Auth ---
|
// --- YouTube Auth ---
|
||||||
|
|
||||||
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
|
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ use crate::types::*;
|
|||||||
|
|
||||||
#[function_component(PlaylistsPage)]
|
#[function_component(PlaylistsPage)]
|
||||||
pub fn playlists_page() -> Html {
|
pub fn playlists_page() -> Html {
|
||||||
|
// Tab state: "saved" | "generate" | "edit"
|
||||||
|
let active_tab = use_state(|| "saved".to_string());
|
||||||
|
let editing_playlist = use_state(|| None::<PlaylistDetail>);
|
||||||
|
|
||||||
// All local artists (for seed picker)
|
// All local artists (for seed picker)
|
||||||
let all_artists = use_state(Vec::<ArtistListItem>::new);
|
let all_artists = use_state(Vec::<ArtistListItem>::new);
|
||||||
{
|
{
|
||||||
@@ -20,23 +24,41 @@ pub fn playlists_page() -> Html {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All tracks (for add-track search in edit tab)
|
||||||
|
let all_tracks = use_state(Vec::<Track>::new);
|
||||||
|
{
|
||||||
|
let all_tracks = all_tracks.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(tracks) = api::search_tracks("").await {
|
||||||
|
all_tracks.set(tracks);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Generate form state
|
// Generate form state
|
||||||
let seed_input = use_state(String::new);
|
let seed_input = use_state(String::new);
|
||||||
let seed_focused = use_state(|| false);
|
let seed_focused = use_state(|| false);
|
||||||
let seeds = use_state(Vec::<String>::new);
|
let seeds = use_state(Vec::<String>::new);
|
||||||
let count = use_state(|| 50usize);
|
let count = use_state(|| 50usize);
|
||||||
let popularity_bias = use_state(|| 5u8);
|
let popularity_bias = use_state(|| 5u8);
|
||||||
let ordering = use_state(|| "interleave".to_string()); // "score" | "interleave" | "random"
|
let ordering = use_state(|| "interleave".to_string());
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
let generated = use_state(|| None::<GeneratedPlaylist>);
|
let generated = use_state(|| None::<GeneratedPlaylist>);
|
||||||
let saved_playlists = use_state(|| None::<Vec<PlaylistSummary>>);
|
let saved_playlists = use_state(|| None::<Vec<PlaylistSummary>>);
|
||||||
let viewing_playlist = use_state(|| None::<PlaylistDetail>);
|
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
let message = use_state(|| None::<String>);
|
let message = use_state(|| None::<String>);
|
||||||
let loading = use_state(|| false);
|
let loading = use_state(|| false);
|
||||||
let save_name = use_state(String::new);
|
let save_name = use_state(String::new);
|
||||||
|
|
||||||
|
// Edit tab state
|
||||||
|
let edit_name = use_state(String::new);
|
||||||
|
let track_search_input = use_state(String::new);
|
||||||
|
let track_search_focused = use_state(|| false);
|
||||||
|
let dragging_index = use_state(|| None::<usize>);
|
||||||
|
|
||||||
// Load saved playlists on mount
|
// Load saved playlists on mount
|
||||||
let refresh_playlists = {
|
let refresh_playlists = {
|
||||||
let saved_playlists = saved_playlists.clone();
|
let saved_playlists = saved_playlists.clone();
|
||||||
@@ -110,12 +132,14 @@ pub fn playlists_page() -> Html {
|
|||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let message = message.clone();
|
let message = message.clone();
|
||||||
let refresh = refresh_playlists.clone();
|
let refresh = refresh_playlists.clone();
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
Callback::from(move |_: MouseEvent| {
|
Callback::from(move |_: MouseEvent| {
|
||||||
let generated = generated.clone();
|
let generated = generated.clone();
|
||||||
let name = (*save_name).clone();
|
let name = (*save_name).clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let message = message.clone();
|
let message = message.clone();
|
||||||
let refresh = refresh.clone();
|
let refresh = refresh.clone();
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
error.set(Some("Enter a playlist name".to_string()));
|
error.set(Some("Enter a playlist name".to_string()));
|
||||||
return;
|
return;
|
||||||
@@ -127,6 +151,7 @@ pub fn playlists_page() -> Html {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
message.set(Some(format!("Saved playlist: {name}")));
|
message.set(Some(format!("Saved playlist: {name}")));
|
||||||
refresh.emit(());
|
refresh.emit(());
|
||||||
|
active_tab.set("saved".to_string());
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
}
|
}
|
||||||
@@ -135,21 +160,184 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seed artist inputs
|
// --- Tab bar ---
|
||||||
|
let tab_bar = {
|
||||||
|
let active = (*active_tab).clone();
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
html! {
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button
|
||||||
|
class={classes!("tab-btn", (active == "saved").then_some("active"))}
|
||||||
|
onclick={{
|
||||||
|
let tab = tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("saved".to_string()))
|
||||||
|
}}
|
||||||
|
>{ "Saved Playlists" }</button>
|
||||||
|
<button
|
||||||
|
class={classes!("tab-btn", (active == "generate").then_some("active"))}
|
||||||
|
onclick={{
|
||||||
|
let tab = tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("generate".to_string()))
|
||||||
|
}}
|
||||||
|
>{ "Generate" }</button>
|
||||||
|
if let Some(ref detail) = *editing {
|
||||||
|
<button
|
||||||
|
class={classes!("tab-btn", (active == "edit").then_some("active"))}
|
||||||
|
onclick={{
|
||||||
|
let tab = tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("edit".to_string()))
|
||||||
|
}}
|
||||||
|
>{ format!("Edit: {}", detail.playlist.name) }</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tab 1: Saved Playlists ---
|
||||||
|
let saved_tab = {
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
let editing_playlist = editing_playlist.clone();
|
||||||
|
let edit_name = edit_name.clone();
|
||||||
|
let saved_playlists = saved_playlists.clone();
|
||||||
|
let refresh_playlists = refresh_playlists.clone();
|
||||||
|
|
||||||
|
let saved_list = if let Some(ref playlists) = *saved_playlists {
|
||||||
|
if playlists.is_empty() {
|
||||||
|
html! { <p class="text-muted">{ "No saved playlists." }</p> }
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{ "Name" }</th>
|
||||||
|
<th>{ "Tracks" }</th>
|
||||||
|
<th>{ "Created" }</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for playlists.iter().map(|p| {
|
||||||
|
let id = p.id;
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
let en = edit_name.clone();
|
||||||
|
html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ &p.name }</td>
|
||||||
|
<td>{ p.track_count }</td>
|
||||||
|
<td class="text-sm text-muted">{ &p.created_at }</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick={{
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
let en = en.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
let en = en.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Ok(detail) = api::get_playlist(id).await {
|
||||||
|
en.set(detail.playlist.name.clone());
|
||||||
|
editing.set(Some(detail));
|
||||||
|
tab.set("edit".to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "Edit" }</button>
|
||||||
|
<a href={api::export_m3u_url(id)}
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
download="true">{ "M3U" }</a>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick={{
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::delete_playlist(id).await;
|
||||||
|
// If we're editing this playlist, clear it
|
||||||
|
if let Some(ref d) = *editing {
|
||||||
|
if d.playlist.id == id {
|
||||||
|
editing.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh.emit(());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "Delete" }</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <p class="loading">{ "Loading..." }</p> }
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<button class="btn btn-primary" onclick={{
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| tab.set("generate".to_string()))
|
||||||
|
}}>{ "Generate" }</button>
|
||||||
|
<button class="btn btn-secondary" onclick={{
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
let edit_name = edit_name.clone();
|
||||||
|
let tab = active_tab.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let edit_name = edit_name.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::save_playlist("New Playlist", None, &[]).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
// Extract the new playlist id and open it for editing
|
||||||
|
if let Some(id) = resp.get("id").and_then(|v| v.as_i64()) {
|
||||||
|
let id = id as i32;
|
||||||
|
edit_name.set("New Playlist".to_string());
|
||||||
|
if let Ok(detail) = api::get_playlist(id).await {
|
||||||
|
editing.set(Some(detail));
|
||||||
|
tab.set("edit".to_string());
|
||||||
|
}
|
||||||
|
refresh.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "New" }</button>
|
||||||
|
</div>
|
||||||
|
{ saved_list }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tab 2: Generate ---
|
||||||
|
let generate_tab = {
|
||||||
let strategy_inputs = {
|
let strategy_inputs = {
|
||||||
{
|
|
||||||
let seed_input_c = seed_input.clone();
|
let seed_input_c = seed_input.clone();
|
||||||
let seeds_c = seeds.clone();
|
let seeds_c = seeds.clone();
|
||||||
// Filter artists by input text (case-insensitive subsequence match)
|
|
||||||
let query = (*seed_input_c).to_lowercase();
|
let query = (*seed_input_c).to_lowercase();
|
||||||
let filtered: Vec<_> = {
|
let filtered: Vec<_> = (*all_artists)
|
||||||
(*all_artists).iter()
|
.iter()
|
||||||
.filter(|a| {
|
.filter(|a| {
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let name_lower = a.name.to_lowercase();
|
let name_lower = a.name.to_lowercase();
|
||||||
// Subsequence match like drift's fuzzy_match
|
|
||||||
let mut chars = query.chars();
|
let mut chars = query.chars();
|
||||||
let mut current = chars.next();
|
let mut current = chars.next();
|
||||||
for c in name_lower.chars() {
|
for c in name_lower.chars() {
|
||||||
@@ -162,8 +350,7 @@ pub fn playlists_page() -> Html {
|
|||||||
.filter(|a| !seeds_c.contains(&a.name))
|
.filter(|a| !seeds_c.contains(&a.name))
|
||||||
.take(15)
|
.take(15)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect();
|
||||||
};
|
|
||||||
html! {
|
html! {
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{ "Seed Artists" }</label>
|
<label>{ "Seed Artists" }</label>
|
||||||
@@ -185,10 +372,10 @@ pub fn playlists_page() -> Html {
|
|||||||
}}
|
}}
|
||||||
onblur={{
|
onblur={{
|
||||||
let f = seed_focused.clone();
|
let f = seed_focused.clone();
|
||||||
// Delay to allow click on dropdown item
|
|
||||||
Callback::from(move |_: FocusEvent| {
|
Callback::from(move |_: FocusEvent| {
|
||||||
let f = f.clone();
|
let f = f.clone();
|
||||||
gloo_timers::callback::Timeout::new(200, move || f.set(false)).forget();
|
gloo_timers::callback::Timeout::new(200, move || f.set(false))
|
||||||
|
.forget();
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -222,7 +409,7 @@ pub fn playlists_page() -> Html {
|
|||||||
let mut v = (*seeds).clone();
|
let mut v = (*seeds).clone();
|
||||||
v.remove(i);
|
v.remove(i);
|
||||||
seeds.set(v);
|
seeds.set(v);
|
||||||
})}>{ s }{ " \u{00d7}" }</span>
|
})}>{s}{ " \u{00d7}" }</span>
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -257,13 +444,11 @@ pub fn playlists_page() -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track list display
|
|
||||||
let track_list = if let Some(ref gen) = *generated {
|
let track_list = if let Some(ref gen) = *generated {
|
||||||
html! {
|
html! {
|
||||||
<div class="card">
|
<div class="card" style="margin-top: 1rem;">
|
||||||
<h3>{ format!("Generated Tracks ({})", gen.tracks.len()) }</h3>
|
<h3>{ format!("Generated Tracks ({})", gen.tracks.len()) }</h3>
|
||||||
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
|
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
|
||||||
<input
|
<input
|
||||||
@@ -308,120 +493,8 @@ pub fn playlists_page() -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Saved playlists display
|
|
||||||
let saved_list = if let Some(ref playlists) = *saved_playlists {
|
|
||||||
if playlists.is_empty() {
|
|
||||||
html! { <p class="text-muted">{ "No saved playlists." }</p> }
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{ "Name" }</th>
|
|
||||||
<th>{ "Tracks" }</th>
|
|
||||||
<th>{ "Created" }</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ for playlists.iter().map(|p| {
|
|
||||||
let id = p.id;
|
|
||||||
let refresh = refresh_playlists.clone();
|
|
||||||
let viewing = viewing_playlist.clone();
|
|
||||||
html! {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="#" onclick={{
|
|
||||||
let viewing = viewing.clone();
|
|
||||||
Callback::from(move |e: MouseEvent| {
|
|
||||||
e.prevent_default();
|
|
||||||
let viewing = viewing.clone();
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
if let Ok(detail) = api::get_playlist(id).await {
|
|
||||||
viewing.set(Some(detail));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}}>{ &p.name }</a>
|
|
||||||
</td>
|
|
||||||
<td>{ p.track_count }</td>
|
|
||||||
<td class="text-sm text-muted">{ &p.created_at }</td>
|
|
||||||
<td>
|
|
||||||
<a href={api::export_m3u_url(id)}
|
|
||||||
class="btn btn-sm btn-secondary"
|
|
||||||
download="true">{ "M3U" }</a>
|
|
||||||
<button class="btn btn-sm btn-danger" onclick={{
|
|
||||||
let refresh = refresh.clone();
|
|
||||||
let viewing = viewing.clone();
|
|
||||||
Callback::from(move |_: MouseEvent| {
|
|
||||||
let refresh = refresh.clone();
|
|
||||||
let viewing = viewing.clone();
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let _ = api::delete_playlist(id).await;
|
|
||||||
viewing.set(None);
|
|
||||||
refresh.emit(());
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}}>{ "Delete" }</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! { <p class="loading">{ "Loading..." }</p> }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Viewing a saved playlist
|
|
||||||
let viewing_section = if let Some(ref detail) = *viewing_playlist {
|
|
||||||
html! {
|
|
||||||
<div class="card">
|
|
||||||
<h3>{ &detail.playlist.name }</h3>
|
|
||||||
if let Some(ref desc) = detail.playlist.description {
|
|
||||||
<p class="text-muted">{ desc }</p>
|
|
||||||
}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{ "#" }</th>
|
|
||||||
<th>{ "Title" }</th>
|
|
||||||
<th>{ "Artist" }</th>
|
|
||||||
<th>{ "Album" }</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ for detail.tracks.iter().enumerate().map(|(i, t)| html! {
|
|
||||||
<tr>
|
|
||||||
<td>{ i + 1 }</td>
|
|
||||||
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
|
||||||
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
|
|
||||||
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
|
|
||||||
</tr>
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
|
||||||
<h2>{ "Playlists" }</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
if let Some(ref msg) = *message {
|
|
||||||
<div class="card" style="border-color: var(--success);">{ msg }</div>
|
|
||||||
}
|
|
||||||
if let Some(ref err) = *error {
|
|
||||||
<div class="card error">{ err }</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>{ "Generate Playlist" }</h3>
|
<h3>{ "Generate Playlist" }</h3>
|
||||||
<p class="text-muted text-sm">{ "Build a playlist from similar artists in your library using Last.fm data." }</p>
|
<p class="text-muted text-sm">{ "Build a playlist from similar artists in your library using Last.fm data." }</p>
|
||||||
@@ -454,13 +527,333 @@ pub fn playlists_page() -> Html {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ track_list }
|
{ track_list }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tab 3: Edit ---
|
||||||
|
let edit_tab = {
|
||||||
|
let editing = editing_playlist.clone();
|
||||||
|
let active_tab_c = active_tab.clone();
|
||||||
|
let edit_name_c = edit_name.clone();
|
||||||
|
let refresh = refresh_playlists.clone();
|
||||||
|
let error_c = error.clone();
|
||||||
|
let message_c = message.clone();
|
||||||
|
let track_search_input_c = track_search_input.clone();
|
||||||
|
let track_search_focused_c = track_search_focused.clone();
|
||||||
|
let all_tracks_c = all_tracks.clone();
|
||||||
|
let dragging_index_c = dragging_index.clone();
|
||||||
|
|
||||||
|
if let Some(ref detail) = *editing {
|
||||||
|
let playlist_id = detail.playlist.id;
|
||||||
|
let tracks = detail.tracks.clone();
|
||||||
|
|
||||||
|
// Filter tracks for add-track search
|
||||||
|
let search_query = (*track_search_input_c).to_lowercase();
|
||||||
|
let existing_ids: Vec<i32> = tracks.iter().map(|t| t.id).collect();
|
||||||
|
let filtered_tracks: Vec<_> = if search_query.is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
(*all_tracks_c)
|
||||||
|
.iter()
|
||||||
|
.filter(|t| {
|
||||||
|
if existing_ids.contains(&t.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let title_lower = t
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let artist_lower = t
|
||||||
|
.artist
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
// Subsequence match on title or artist
|
||||||
|
let matches_field = |field: &str| {
|
||||||
|
let mut chars = search_query.chars();
|
||||||
|
let mut current = chars.next();
|
||||||
|
for c in field.chars() {
|
||||||
|
if current == Some(c) {
|
||||||
|
current = chars.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.is_none()
|
||||||
|
};
|
||||||
|
matches_field(&title_lower) || matches_field(&artist_lower)
|
||||||
|
})
|
||||||
|
.take(15)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-secondary" style="margin-bottom: 1rem;" onclick={{
|
||||||
|
let tab = active_tab_c.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
editing.set(None);
|
||||||
|
tab.set("saved".to_string());
|
||||||
|
})
|
||||||
|
}}>{ "Back to Playlists" }</button>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>{ "Saved Playlists" }</h3>
|
<div class="form-group">
|
||||||
{ saved_list }
|
<label>{ "Playlist Name" }</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(*edit_name_c).clone()}
|
||||||
|
oninput={{
|
||||||
|
let en = edit_name_c.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
en.set(input.value());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" onclick={{
|
||||||
|
let en = edit_name_c.clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let message = message_c.clone();
|
||||||
|
let error = error_c.clone();
|
||||||
|
let tab = active_tab_c.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let name = (*en).clone();
|
||||||
|
let editing = editing.clone();
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
let message = message.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let tab = tab.clone();
|
||||||
|
if name.is_empty() {
|
||||||
|
error.set(Some("Name cannot be empty".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_playlist(playlist_id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let body = serde_json::json!({"name": name}).to_string();
|
||||||
|
let resp = gloo_net::http::Request::put(
|
||||||
|
&format!("/api/playlists/{playlist_id}"),
|
||||||
|
)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(&body)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match resp {
|
||||||
|
Ok(r) if r.ok() => {
|
||||||
|
message.set(Some("Playlist saved".to_string()));
|
||||||
|
refresh.emit(());
|
||||||
|
editing.set(None);
|
||||||
|
tab.set("saved".to_string());
|
||||||
|
}
|
||||||
|
Ok(r) => error.set(Some(format!("HTTP {}", r.status()))),
|
||||||
|
Err(e) => error.set(Some(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}}>{ "Save" }</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ viewing_section }
|
<div class="card">
|
||||||
|
<h3>{ format!("Tracks ({})", tracks.len()) }</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 2rem;"></th>
|
||||||
|
<th style="width: 2rem;">{ "#" }</th>
|
||||||
|
<th>{ "Title" }</th>
|
||||||
|
<th>{ "Artist" }</th>
|
||||||
|
<th>{ "Album" }</th>
|
||||||
|
<th style="width: 3rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for tracks.iter().enumerate().map(|(i, t)| {
|
||||||
|
let track_id = t.id;
|
||||||
|
let editing = editing.clone();
|
||||||
|
let dragging = dragging_index_c.clone();
|
||||||
|
let idx = i;
|
||||||
|
|
||||||
|
let ondragstart = {
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
Callback::from(move |e: DragEvent| {
|
||||||
|
dragging.set(Some(idx));
|
||||||
|
if let Some(dt) = e.data_transfer() {
|
||||||
|
let _ = dt.set_data("text/plain", &idx.to_string());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let ondragover = Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
});
|
||||||
|
|
||||||
|
let ondrop = {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
Callback::from(move |e: DragEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
let target_idx = idx;
|
||||||
|
if let Some(source_idx) = *dragging {
|
||||||
|
if source_idx != target_idx {
|
||||||
|
let editing_inner = editing.clone();
|
||||||
|
let dragging_inner = dragging.clone();
|
||||||
|
// Reorder locally
|
||||||
|
if let Some(ref detail) = *editing {
|
||||||
|
let mut new_tracks = detail.tracks.clone();
|
||||||
|
let item = new_tracks.remove(source_idx);
|
||||||
|
new_tracks.insert(target_idx, item);
|
||||||
|
let track_ids: Vec<i32> = new_tracks.iter().map(|t| t.id).collect();
|
||||||
|
let new_detail = PlaylistDetail {
|
||||||
|
playlist: detail.playlist.clone(),
|
||||||
|
tracks: new_tracks,
|
||||||
|
};
|
||||||
|
editing_inner.set(Some(new_detail));
|
||||||
|
dragging_inner.set(None);
|
||||||
|
// Call reorder API
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::reorder_playlist_tracks(playlist_id, &track_ids).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragging.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_remove = {
|
||||||
|
let editing = editing.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::remove_track_from_playlist(playlist_id, track_id).await;
|
||||||
|
// Refresh the playlist
|
||||||
|
if let Ok(detail) = api::get_playlist(playlist_id).await {
|
||||||
|
editing.set(Some(detail));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<tr draggable="true"
|
||||||
|
{ondragstart}
|
||||||
|
{ondragover}
|
||||||
|
{ondrop}
|
||||||
|
>
|
||||||
|
<td><span class="drag-handle">{ "\u{2261}" }</span></td>
|
||||||
|
<td>{ i + 1 }</td>
|
||||||
|
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
||||||
|
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
|
||||||
|
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick={on_remove}>
|
||||||
|
{ "\u{00d7}" }
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Add track section
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Add Track" }</h3>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tracks in your library..."
|
||||||
|
value={(*track_search_input_c).clone()}
|
||||||
|
oninput={{
|
||||||
|
let tsi = track_search_input_c.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
tsi.set(input.value());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onfocus={{
|
||||||
|
let f = track_search_focused_c.clone();
|
||||||
|
Callback::from(move |_: FocusEvent| f.set(true))
|
||||||
|
}}
|
||||||
|
onblur={{
|
||||||
|
let f = track_search_focused_c.clone();
|
||||||
|
Callback::from(move |_: FocusEvent| {
|
||||||
|
let f = f.clone();
|
||||||
|
gloo_timers::callback::Timeout::new(200, move || f.set(false)).forget();
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
if *track_search_focused_c && !filtered_tracks.is_empty() {
|
||||||
|
<div class="autocomplete-dropdown">
|
||||||
|
{ for filtered_tracks.iter().map(|t| {
|
||||||
|
let tid = t.id;
|
||||||
|
let title = t.title.clone().unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let artist = t.artist.clone().unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tsi = track_search_input_c.clone();
|
||||||
|
html! {
|
||||||
|
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
let editing = editing.clone();
|
||||||
|
let tsi = tsi.clone();
|
||||||
|
tsi.set(String::new());
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = api::add_track_to_playlist(playlist_id, tid).await;
|
||||||
|
if let Ok(detail) = api::get_playlist(playlist_id).await {
|
||||||
|
editing.set(Some(detail));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
<span>{ format!("{} - {}", title, artist) }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <p class="text-muted">{ "No playlist selected for editing." }</p> }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{ "Playlists" }</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if let Some(ref msg) = *message {
|
||||||
|
<div class="card" style="border-color: var(--success);">{ msg }</div>
|
||||||
|
}
|
||||||
|
if let Some(ref err) = *error {
|
||||||
|
<div class="card error">{ err }</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{ tab_bar }
|
||||||
|
|
||||||
|
if *active_tab == "saved" {
|
||||||
|
{ saved_tab }
|
||||||
|
}
|
||||||
|
if *active_tab == "generate" {
|
||||||
|
{ generate_tab }
|
||||||
|
}
|
||||||
|
if *active_tab == "edit" {
|
||||||
|
{ edit_tab }
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,18 @@ table.tasks-table td { overflow: hidden; text-overflow: ellipsis; }
|
|||||||
.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; }
|
.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; }
|
||||||
.auth-card p { margin-bottom: 1.5rem; }
|
.auth-card p { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
/* Tab bar */
|
||||||
|
.tab-bar { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid var(--border); }
|
||||||
|
.tab-btn { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; font-size: 0.9rem; }
|
||||||
|
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
.tab-btn:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Drag and drop */
|
||||||
|
.drag-handle { cursor: grab; user-select: none; color: var(--text-muted); padding-right: 0.5rem; font-size: 1.2rem; }
|
||||||
|
tr.drag-over { background: rgba(59, 130, 246, 0.1); }
|
||||||
|
tr[draggable="true"] { cursor: grab; }
|
||||||
|
tr[draggable="true"]:active { cursor: grabbing; }
|
||||||
|
|
||||||
/* Sidebar user section */
|
/* Sidebar user section */
|
||||||
.sidebar-user {
|
.sidebar-user {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|||||||
@@ -22,7 +22,16 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::put().to(update_playlist))
|
.route(web::put().to(update_playlist))
|
||||||
.route(web::delete().to(delete_playlist)),
|
.route(web::delete().to(delete_playlist)),
|
||||||
)
|
)
|
||||||
.service(web::resource("/playlists/{id}/m3u").route(web::get().to(export_m3u)));
|
.service(web::resource("/playlists/{id}/m3u").route(web::get().to(export_m3u)))
|
||||||
|
.service(
|
||||||
|
web::resource("/playlists/{id}/tracks")
|
||||||
|
.route(web::post().to(add_track))
|
||||||
|
.route(web::put().to(reorder_tracks)),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/playlists/{id}/tracks/{track_id}")
|
||||||
|
.route(web::delete().to(remove_track)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/playlists/generate — generate a playlist without saving.
|
/// POST /api/playlists/generate — generate a playlist without saving.
|
||||||
@@ -194,6 +203,56 @@ async fn delete_playlist(
|
|||||||
Ok(HttpResponse::NoContent().finish())
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AddTrackRequest {
|
||||||
|
track_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/playlists/{id}/tracks — add a track to playlist.
|
||||||
|
async fn add_track(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
body: web::Json<AddTrackRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let req = body.into_inner();
|
||||||
|
queries::playlists::add_track(state.db.conn(), id, req.track_id).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReorderTracksRequest {
|
||||||
|
track_ids: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/playlists/{id}/tracks — reorder tracks in playlist.
|
||||||
|
async fn reorder_tracks(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
body: web::Json<ReorderTracksRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let req = body.into_inner();
|
||||||
|
queries::playlists::reorder_tracks(state.db.conn(), id, req.track_ids).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/playlists/{id}/tracks/{track_id} — remove a track from playlist.
|
||||||
|
async fn remove_track(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(i32, i32)>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let (id, track_id) = path.into_inner();
|
||||||
|
queries::playlists::remove_track(state.db.conn(), id, track_id).await?;
|
||||||
|
Ok(HttpResponse::NoContent().finish())
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/playlists/{id}/m3u — export as M3U file.
|
/// GET /api/playlists/{id}/m3u — export as M3U file.
|
||||||
async fn export_m3u(
|
async fn export_m3u(
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
|
|||||||
Reference in New Issue
Block a user