From abe321a3176bb797b107981f59b884fddb9b4c9b Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Fri, 20 Mar 2026 18:36:59 -0400 Subject: [PATCH] Added the playlist editor --- frontend/Cargo.toml | 2 +- frontend/src/api.rs | 44 ++ frontend/src/pages/playlists.rs | 937 +++++++++++++++++++++++--------- frontend/style.css | 12 + src/routes/playlists.rs | 61 ++- 5 files changed, 782 insertions(+), 274 deletions(-) diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 662fcd7..47e5ee8 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -14,5 +14,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" wasm-bindgen = "0.2" 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" diff --git a/frontend/src/api.rs b/frontend/src/api.rs index b19ee73..2852aac 100644 --- a/frontend/src/api.rs +++ b/frontend/src/api.rs @@ -44,6 +44,20 @@ async fn post_empty(url: &str) -> Result { resp.json().await.map_err(|e| ApiError(e.to_string())) } +async fn put_json(url: &str, body: &str) -> Result { + 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> { let resp = Request::delete(url) .send() @@ -304,6 +318,36 @@ pub fn export_m3u_url(id: i32) -> String { format!("{BASE}/playlists/{id}/m3u") } +pub async fn add_track_to_playlist( + playlist_id: i32, + track_id: i32, +) -> Result { + 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 { + 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, ApiError> { + get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await +} + // --- YouTube Auth --- pub async fn get_ytauth_status() -> Result { diff --git a/frontend/src/pages/playlists.rs b/frontend/src/pages/playlists.rs index 0d4310c..6e33b49 100644 --- a/frontend/src/pages/playlists.rs +++ b/frontend/src/pages/playlists.rs @@ -7,6 +7,10 @@ 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::); + // All local artists (for seed picker) let all_artists = use_state(Vec::::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::::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::::new); let count = use_state(|| 50usize); let popularity_bias = use_state(|| 5u8); - let ordering = use_state(|| "interleave".to_string()); // "score" | "interleave" | "random" + let ordering = use_state(|| "interleave".to_string()); // Results let generated = use_state(|| None::); let saved_playlists = use_state(|| None::>); - let viewing_playlist = use_state(|| None::); let error = use_state(|| None::); let message = use_state(|| None::); 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::); + // Load saved playlists on mount let refresh_playlists = { let saved_playlists = saved_playlists.clone(); @@ -110,12 +132,14 @@ pub fn playlists_page() -> Html { 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; @@ -127,6 +151,7 @@ pub fn playlists_page() -> Html { Ok(_) => { message.set(Some(format!("Saved playlist: {name}"))); refresh.emit(()); + active_tab.set("saved".to_string()); } Err(e) => error.set(Some(e.0)), } @@ -135,278 +160,674 @@ pub fn playlists_page() -> Html { }) }; - // Seed artist inputs - let strategy_inputs = { - { - let seed_input_c = seed_input.clone(); - let seeds_c = seeds.clone(); - // Filter artists by input text (case-insensitive subsequence match) - let query = (*seed_input_c).to_lowercase(); - let filtered: Vec<_> = { - (*all_artists).iter() - .filter(|a| { - if query.is_empty() { - return true; + // --- Tab bar --- + let tab_bar = { + let active = (*active_tab).clone(); + let tab = active_tab.clone(); + let editing = editing_playlist.clone(); + html! { +
+ + + if let Some(ref detail) = *editing { + + } +
+ } + }; + + // --- 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! {

{ "No saved playlists." }

} + } else { + html! { + + + + + + + + + + + { 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! { + + + + + + + } + })} + +
{ "Name" }{ "Tracks" }{ "Created" }
{ &p.name }{ p.track_count }{ &p.created_at } +
+ + { "M3U" } + +
+
+ } + } + } else { + html! {

{ "Loading..." }

} + }; + + html! { +
+
+ + +
+ { saved_list } +
+ } + }; + + // --- 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! { +
+ +
+ + if *seed_focused && !filtered.is_empty() { +
+ { 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! { +
+ { &a.name } + { format!("{} tracks", track_count) } +
+ } + })} +
+ } +
+
+ { for seeds_c.iter().enumerate().map(|(i, s)| { + let seeds = seeds_c.clone(); + html! { + {s}{ " \u{00d7}" } } - let name_lower = a.name.to_lowercase(); - // Subsequence match like drift's fuzzy_match - let mut chars = query.chars(); + })} +
+ + +
+ + +
+
+ } + }; + + let track_list = if let Some(ref gen) = *generated { + html! { +
+

{ format!("Generated Tracks ({})", gen.tracks.len()) }

+
+ + +
+ + + + + + + + + + + + { for gen.tracks.iter().enumerate().map(|(i, t)| html! { + + + + + + + + })} + +
{ "#" }{ "Title" }{ "Artist" }{ "Album" }{ "Score" }
{ i + 1 }{ t.title.as_deref().unwrap_or("Unknown") }{ t.artist.as_deref().unwrap_or("Unknown") }{ t.album.as_deref().unwrap_or("") }{ format!("{:.3}", t.score) }
+
+ } + } else { + html! {} + }; + + html! { +
+
+

{ "Generate Playlist" }

+

{ "Build a playlist from similar artists in your library using Last.fm data." }

+ + { strategy_inputs } + +
+ + +
+ + +
+ + { track_list } +
+ } + }; + + // --- 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 = 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 name_lower.chars() { + 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! { +
+ + +
+
+ +
+ + +
+
+
+ +
+

{ format!("Tracks ({})", tracks.len()) }

+ + + + + + + + + + + + + { 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 = 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! { + + + + + + + + + } + })} + +
{ "#" }{ "Title" }{ "Artist" }{ "Album" }
{ "\u{2261}" }{ i + 1 }{ t.title.as_deref().unwrap_or("Unknown") }{ t.artist.as_deref().unwrap_or("Unknown") }{ t.album.as_deref().unwrap_or("") } + +
+
+ + // Add track section +
+

{ "Add Track" }

- if *seed_focused && !filtered.is_empty() { + if *track_search_focused_c && !filtered_tracks.is_empty() {
- { 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; + { 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! {
- { &a.name } - { format!("{} tracks", track_count) } + { format!("{} - {}", title, artist) }
} })}
}
-
- { for seeds_c.iter().enumerate().map(|(i, s)| { - let seeds = seeds_c.clone(); - html! { - { s }{ " \u{00d7}" } - } - })} -
- - -
- - -
- } - } - }; - - // Track list display - let track_list = if let Some(ref gen) = *generated { - html! { -
-

{ format!("Generated Tracks ({})", gen.tracks.len()) }

-
- -
- - - - - - - - - - - - { for gen.tracks.iter().enumerate().map(|(i, t)| html! { - - - - - - - - })} - -
{ "#" }{ "Title" }{ "Artist" }{ "Album" }{ "Score" }
{ i + 1 }{ t.title.as_deref().unwrap_or("Unknown") }{ t.artist.as_deref().unwrap_or("Unknown") }{ t.album.as_deref().unwrap_or("") }{ format!("{:.3}", t.score) }
-
- } - } else { - html! {} - }; - - // Saved playlists display - let saved_list = if let Some(ref playlists) = *saved_playlists { - if playlists.is_empty() { - html! {

{ "No saved playlists." }

} - } else { - html! { - - - - - - - - - - - { for playlists.iter().map(|p| { - let id = p.id; - let refresh = refresh_playlists.clone(); - let viewing = viewing_playlist.clone(); - html! { - - - - - - - } - })} - -
{ "Name" }{ "Tracks" }{ "Created" }
- { &p.name } - { p.track_count }{ &p.created_at } - { "M3U" } - -
} + } else { + html! {

{ "No playlist selected for editing." }

} } - } else { - html! {

{ "Loading..." }

} - }; - - // Viewing a saved playlist - let viewing_section = if let Some(ref detail) = *viewing_playlist { - html! { -
-

{ &detail.playlist.name }

- if let Some(ref desc) = detail.playlist.description { -

{ desc }

- } - - - - - - - - - - - { for detail.tracks.iter().enumerate().map(|(i, t)| html! { - - - - - - - })} - -
{ "#" }{ "Title" }{ "Artist" }{ "Album" }
{ i + 1 }{ t.title.as_deref().unwrap_or("Unknown") }{ t.artist.as_deref().unwrap_or("Unknown") }{ t.album.as_deref().unwrap_or("") }
-
- } - } else { - html! {} }; html! { @@ -422,45 +843,17 @@ pub fn playlists_page() -> Html {
{ err }
} -
-

{ "Generate Playlist" }

-

{ "Build a playlist from similar artists in your library using Last.fm data." }

+ { tab_bar } - { strategy_inputs } - -
- - -
- - -
- - { track_list } - -
-

{ "Saved Playlists" }

- { saved_list } -
- - { viewing_section } + if *active_tab == "saved" { + { saved_tab } + } + if *active_tab == "generate" { + { generate_tab } + } + if *active_tab == "edit" { + { edit_tab } + }
} } diff --git a/frontend/style.css b/frontend/style.css index b0f9b32..dd01e0c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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 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 { margin-top: auto; diff --git a/src/routes/playlists.rs b/src/routes/playlists.rs index f4107f5..f4e5de7 100644 --- a/src/routes/playlists.rs +++ b/src/routes/playlists.rs @@ -22,7 +22,16 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route(web::put().to(update_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. @@ -194,6 +203,56 @@ async fn delete_playlist( 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, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + 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, +} + +/// PUT /api/playlists/{id}/tracks — reorder tracks in playlist. +async fn reorder_tracks( + state: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + 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, + session: Session, + path: web::Path<(i32, i32)>, +) -> Result { + 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. async fn export_m3u( state: web::Data,