top songs flow

This commit is contained in:
Connor Johnstone
2026-03-25 15:48:37 -04:00
parent 1a478dea8e
commit 6e156037e6
3 changed files with 255 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ use yew::prelude::*;
use yew_router::prelude::*;
use crate::api;
use crate::components::status_badge::StatusBadge;
use crate::pages::Route;
use crate::types::FullArtistDetail;
@@ -18,6 +19,8 @@ pub fn artist_page(props: &Props) -> Html {
let detail = use_state(|| None::<FullArtistDetail>);
let error = use_state(|| None::<String>);
let message = use_state(|| None::<String>);
let active_tab = use_state(|| "discography".to_string());
let top_songs_limit = use_state(|| 25usize);
let id = props.id.clone();
// Flag to prevent the background enrichment from overwriting a user-triggered refresh
@@ -259,6 +262,151 @@ pub fn artist_page(props: &Props) -> Html {
}
};
let tab_bar_html = {
let has_top_songs = d.lastfm_available && !d.top_songs.is_empty();
let disco_active = *active_tab == "discography";
let top_active = *active_tab == "top_songs";
let on_disco = {
let tab = active_tab.clone();
Callback::from(move |_: MouseEvent| tab.set("discography".to_string()))
};
let on_top = {
let tab = active_tab.clone();
Callback::from(move |_: MouseEvent| tab.set("top_songs".to_string()))
};
html! {
<div class="tab-bar">
<button class={if disco_active { "tab-btn active" } else { "tab-btn" }}
onclick={on_disco}>{ "Discography" }</button>
if has_top_songs {
<button class={if top_active { "tab-btn active" } else { "tab-btn" }}
onclick={on_top}>{ "Top Songs" }</button>
}
</div>
}
};
let top_songs_html = {
let limit = *top_songs_limit;
let visible: Vec<_> = d.top_songs.iter().take(limit).collect();
let has_more = d.top_songs.len() > limit;
let artist_name = d.artist.name.clone();
let on_show_more = {
let top_songs_limit = top_songs_limit.clone();
Callback::from(move |_: MouseEvent| {
top_songs_limit.set(*top_songs_limit + 25);
})
};
html! {
<div>
<table>
<thead>
<tr>
<th style="width:3rem;">{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Plays" }</th>
<th>{ "Status" }</th>
<th></th>
</tr>
</thead>
<tbody>
{ for visible.iter().enumerate().map(|(i, song)| {
let rank = i + 1;
let has_status = song.status.is_some();
let on_watch = {
let detail = detail.clone();
let name = song.name.clone();
let mbid = song.mbid.clone();
let artist = artist_name.clone();
Callback::from(move |_: MouseEvent| {
let detail = detail.clone();
let name = name.clone();
let mbid = mbid.clone();
let artist = artist.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Ok(resp) = api::watch_track(Some(&artist), &name, mbid.as_deref().unwrap_or("")).await {
if let Some(ref d) = *detail {
let mut updated = d.clone();
if let Some(s) = updated.top_songs.get_mut(i) {
s.status = Some(resp.status);
}
detail.set(Some(updated));
}
}
});
})
};
let on_unwatch = {
let detail = detail.clone();
let mbid = song.mbid.clone();
Callback::from(move |_: MouseEvent| {
let detail = detail.clone();
let mbid = mbid.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Some(ref m) = mbid {
if api::unwatch_track(m).await.is_ok() {
if let Some(ref d) = *detail {
let mut updated = d.clone();
if let Some(s) = updated.top_songs.get_mut(i) {
s.status = None;
}
detail.set(Some(updated));
}
}
}
});
})
};
let plays = if song.playcount >= 1_000_000 {
format!("{}M", song.playcount / 1_000_000)
} else if song.playcount >= 1_000 {
format!("{}K", song.playcount / 1_000)
} else {
song.playcount.to_string()
};
html! {
<tr>
<td class="text-muted">{ rank }</td>
<td>{ &song.name }</td>
<td class="text-muted text-sm">{ plays }</td>
<td>
if let Some(ref status) = song.status {
<StatusBadge status={status.clone()} />
}
</td>
<td class="actions">
if !has_status {
<button class="btn btn-sm" onclick={on_watch}>
{ "Watch" }
</button>
} else {
<button class="btn btn-sm btn-secondary" onclick={on_unwatch}>
{ "Unwatch" }
</button>
}
</td>
</tr>
}
})}
</tbody>
</table>
if has_more {
<div style="text-align:center;margin-top:0.5rem;">
<button class="btn btn-sm btn-secondary" onclick={on_show_more}>
{ format!("Show More ({} total)", d.top_songs.len()) }
</button>
</div>
}
</div>
}
};
html! {
<div>
<div class={if d.artist_banner.is_some() { "artist-banner-wrap" } else { "" }}
@@ -343,6 +491,14 @@ pub fn artist_page(props: &Props) -> Html {
</div>
}
// Tab bar
{ tab_bar_html }
if *active_tab == "top_songs" && !d.top_songs.is_empty() {
// Top Songs tab content
{ top_songs_html }
} else {
if d.albums.is_empty() {
<p class="text-muted">{ "No releases found on MusicBrainz." }</p>
}
@@ -575,6 +731,8 @@ pub fn artist_page(props: &Props) -> Html {
</details>
}
})}
} // close else (discography tab)
</div>
}
}

View File

@@ -77,6 +77,19 @@ pub struct FullArtistDetail {
pub artist_bio: Option<String>,
#[serde(default)]
pub artist_banner: Option<String>,
#[serde(default)]
pub top_songs: Vec<TopSongFe>,
#[serde(default)]
pub lastfm_available: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct TopSongFe {
pub name: String,
#[serde(default)]
pub playcount: u64,
pub mbid: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]