Files
web/frontend/src/pages/playlists.rs
2026-03-20 20:04:35 -04:00

852 lines
42 KiB
Rust

use web_sys::HtmlInputElement;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use crate::api;
use crate::types::*;
#[function_component(PlaylistsPage)]
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)
let all_artists = use_state(Vec::<ArtistListItem>::new);
{
let all_artists = all_artists.clone();
use_effect_with((), move |_| {
wasm_bindgen_futures::spawn_local(async move {
if let Ok(artists) = api::list_artists(500, 0).await {
all_artists.set(artists);
}
});
});
}
// 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
let seed_input = use_state(String::new);
let seed_focused = use_state(|| false);
let seeds = use_state(Vec::<String>::new);
let count = use_state(|| 50usize);
let popularity_bias = use_state(|| 5u8);
let ordering = use_state(|| "interleave".to_string());
// Results
let generated = use_state(|| None::<GeneratedPlaylist>);
let saved_playlists = use_state(|| None::<Vec<PlaylistSummary>>);
let error = use_state(|| None::<String>);
let message = use_state(|| None::<String>);
let loading = use_state(|| false);
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
let refresh_playlists = {
let saved_playlists = saved_playlists.clone();
Callback::from(move |_: ()| {
let saved_playlists = saved_playlists.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Ok(list) = api::list_playlists().await {
saved_playlists.set(Some(list));
}
});
})
};
{
let refresh = refresh_playlists.clone();
use_effect_with((), move |_| {
refresh.emit(());
});
}
// Generate handler
let on_generate = {
let seeds = seeds.clone();
let count = count.clone();
let popularity_bias = popularity_bias.clone();
let ordering = ordering.clone();
let generated = generated.clone();
let error = error.clone();
let loading = loading.clone();
let message = message.clone();
Callback::from(move |_: MouseEvent| {
let seeds = (*seeds).clone();
let count = *count;
let popularity_bias = *popularity_bias;
let ordering_val = (*ordering).clone();
let generated = generated.clone();
let error = error.clone();
let loading = loading.clone();
let message = message.clone();
loading.set(true);
error.set(None);
message.set(None);
wasm_bindgen_futures::spawn_local(async move {
let req = GenerateRequest {
strategy: "similar".to_string(),
seed_artists: seeds,
genres: vec![],
count,
popularity_bias,
ordering: ordering_val,
rules: None,
};
match api::generate_playlist(&req).await {
Ok(result) => {
let n = result.tracks.len();
message.set(Some(format!("Generated {n} tracks")));
generated.set(Some(result));
}
Err(e) => error.set(Some(e.0)),
}
loading.set(false);
});
})
};
// Save handler
let on_save = {
let generated = generated.clone();
let save_name = save_name.clone();
let error = error.clone();
let message = message.clone();
let refresh = refresh_playlists.clone();
let active_tab = active_tab.clone();
Callback::from(move |_: MouseEvent| {
let generated = generated.clone();
let name = (*save_name).clone();
let error = error.clone();
let message = message.clone();
let refresh = refresh.clone();
let active_tab = active_tab.clone();
if name.is_empty() {
error.set(Some("Enter a playlist name".to_string()));
return;
}
wasm_bindgen_futures::spawn_local(async move {
if let Some(ref gen) = *generated {
let track_ids: Vec<i32> = gen.tracks.iter().map(|t| t.track_id).collect();
match api::save_playlist(&name, None, &track_ids).await {
Ok(_) => {
message.set(Some(format!("Saved playlist: {name}")));
refresh.emit(());
active_tab.set("saved".to_string());
}
Err(e) => error.set(Some(e.0)),
}
}
});
})
};
// --- 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 seed_input_c = seed_input.clone();
let seeds_c = seeds.clone();
let query = (*seed_input_c).to_lowercase();
let filtered: Vec<_> = (*all_artists)
.iter()
.filter(|a| {
if query.is_empty() {
return true;
}
let name_lower = a.name.to_lowercase();
let mut chars = query.chars();
let mut current = chars.next();
for c in name_lower.chars() {
if current == Some(c) {
current = chars.next();
}
}
current.is_none()
})
.filter(|a| !seeds_c.contains(&a.name))
.take(15)
.cloned()
.collect();
html! {
<div class="form-group">
<label>{ "Seed Artists" }</label>
<div style="position:relative;">
<input
type="text"
placeholder="Search your library..."
value={(*seed_input_c).clone()}
oninput={{
let si = seed_input.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
si.set(input.value());
})
}}
onfocus={{
let f = seed_focused.clone();
Callback::from(move |_: FocusEvent| f.set(true))
}}
onblur={{
let f = seed_focused.clone();
Callback::from(move |_: FocusEvent| {
let f = f.clone();
gloo_timers::callback::Timeout::new(200, move || f.set(false))
.forget();
})
}}
/>
if *seed_focused && !filtered.is_empty() {
<div class="autocomplete-dropdown">
{ for filtered.iter().map(|a| {
let name = a.name.clone();
let seeds = seeds.clone();
let si = seed_input.clone();
let track_count = a.total_items;
html! {
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
let mut v = (*seeds).clone();
v.push(name.clone());
seeds.set(v);
si.set(String::new());
})}>
<span>{ &a.name }</span>
<span class="text-muted text-sm">{ format!("{} tracks", track_count) }</span>
</div>
}
})}
</div>
}
</div>
<div class="tag-list">
{ for seeds_c.iter().enumerate().map(|(i, s)| {
let seeds = seeds_c.clone();
html! {
<span class="tag" onclick={Callback::from(move |_: MouseEvent| {
let mut v = (*seeds).clone();
v.remove(i);
seeds.set(v);
})}>{s}{ " \u{00d7}" }</span>
}
})}
</div>
<label>{ format!("Popularity Bias: {}", *popularity_bias) }</label>
<input
type="range"
min="0" max="10"
value={popularity_bias.to_string()}
oninput={{
let pb = popularity_bias.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
pb.set(v);
}
})
}}
/>
<div class="form-group">
<label>{ "Track Order" }</label>
<select onchange={{
let ord = ordering.clone();
Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
ord.set(select.value());
})
}}>
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option>
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option>
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
</select>
</div>
</div>
}
};
let track_list = if let Some(ref gen) = *generated {
html! {
<div class="card" style="margin-top: 1rem;">
<h3>{ format!("Generated Tracks ({})", gen.tracks.len()) }</h3>
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<input
type="text"
placeholder="Playlist name..."
value={(*save_name).clone()}
oninput={{
let sn = save_name.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
sn.set(input.value());
})
}}
/>
<button class="btn btn-primary" onclick={on_save.clone()}>{ "Save Playlist" }</button>
</div>
<table>
<thead>
<tr>
<th>{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Artist" }</th>
<th>{ "Album" }</th>
<th>{ "Score" }</th>
</tr>
</thead>
<tbody>
{ for gen.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>
<td class="text-sm text-muted">{ format!("{:.3}", t.score) }</td>
</tr>
})}
</tbody>
</table>
</div>
}
} else {
html! {}
};
html! {
<div>
<div class="card">
<h3>{ "Generate Playlist" }</h3>
<p class="text-muted text-sm">{ "Build a playlist from similar artists in your library using Last.fm data." }</p>
{ strategy_inputs }
<div class="form-group">
<label>{ format!("Count: {}", *count) }</label>
<input type="range" min="10" max="200" step="10"
value={count.to_string()}
oninput={{
let count = count.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
count.set(v);
}
})
}}
/>
</div>
<button
class="btn btn-primary"
onclick={on_generate}
disabled={*loading}
>
{ if *loading { "Generating..." } else { "Generate" } }
</button>
</div>
{ 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="form-group">
<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 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>
}
}