diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 47e5ee8..8bf4cd8 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", "DragEvent", "DataTransfer"] } +web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window", "DragEvent", "DataTransfer", "DomRect", "DomTokenList", "NodeList", "Element", "HtmlElement", "CssStyleDeclaration", "DomStringMap"] } js-sys = "0.3" diff --git a/frontend/src/pages/library.rs b/frontend/src/pages/library.rs index eee107b..361ec53 100644 --- a/frontend/src/pages/library.rs +++ b/frontend/src/pages/library.rs @@ -35,6 +35,111 @@ pub fn library_page() -> Html { }); } + // Measure DOM heights after render and set spacer flex-grow values directly. + // Must be called before any early returns to maintain consistent hook order. + { + let artist_count = artists.as_ref().map(|a| a.len()).unwrap_or(0); + use_effect_with(artist_count, move |_| { + if let Some(window) = web_sys::window() { + if let Some(doc) = window.document() { + // a. height of the first row + let row_h = doc + .query_selector("#library-table tbody tr") + .ok() + .flatten() + .and_then(|el| el.dyn_into::().ok()) + .map(|el| el.offset_height() as f64) + .unwrap_or(0.0); + + // b. px from top of page to first artist row + let header_h = doc + .query_selector("[id^='letter-']") + .map(|el| { + el.map(|el| { + let rect = el.get_bounding_client_rect(); + rect.top() + window.scroll_y().unwrap_or(0.0) + }) + .unwrap_or(0.0) + }) + .unwrap_or(0.0); + + // c. footer = distance from last row bottom to document bottom + let doc_h = doc + .document_element() + .map(|el| el.scroll_height() as f64) + .unwrap_or(0.0); + let last_row_bottom = doc + .query_selector("#library-table tbody tr:last-child") + .ok() + .flatten() + .map(|el| { + let rect = el.get_bounding_client_rect(); + rect.bottom() + window.scroll_y().unwrap_or(0.0) + }) + .unwrap_or(doc_h); + let footer_h = (doc_h - last_row_bottom).max(0.0); + + // d. Compute percentage heights for all track elements. + // Total page = header + table rows + footer + let num_rows = artist_count.max(1) as f64; + let table_h = num_rows * row_h; + let total_h = header_h + table_h + footer_h; + if total_h <= 0.0 { + return; + } + + let header_pct = header_h / total_h * 100.0; + let footer_pct = footer_h / total_h * 100.0; + let table_pct = table_h / total_h * 100.0; + + // Set header spacer height + if let Some(el) = doc.get_element_by_id("scroll-track-header-spacer") { + if let Ok(el) = el.dyn_into::() { + let _ = el + .style() + .set_property("height", &format!("{header_pct:.2}%")); + } + } + // Set footer spacer height + if let Some(el) = doc.get_element_by_id("scroll-track-footer-spacer") { + if let Ok(el) = el.dyn_into::() { + let _ = el + .style() + .set_property("height", &format!("{footer_pct:.2}%")); + } + } + // Set each letter's height (letter_fraction * table_pct) + let track_h = window + .inner_height() + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(800.0); + if let Ok(letters) = doc.query_selector_all(".scroll-track-letter") { + for i in 0..letters.length() { + if let Some(el) = letters.item(i) { + if let Ok(el) = el.dyn_into::() { + if let Some(grow_str) = el.dataset().get("grow") { + if let Ok(grow) = grow_str.parse::() { + let pct = grow * table_pct; + let px = pct / 100.0 * track_h; + let _ = el + .style() + .set_property("height", &format!("{pct:.2}%")); + // Hide text if cell is too short to fit it + if px < 18.0 { + let _ = el.style().set_property("font-size", "0"); + } + } + } + } + } + } + } + } + } + }); + } + if let Some(ref err) = *error { return html! {
{ format!("Error: {err}") }
}; } @@ -61,13 +166,33 @@ pub fn library_page() -> Html { } }) .collect(); - let active_letters: HashSet = first_of_letter.values().copied().collect(); - // Build scroll track letter data - let letters: Vec = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); - let scroll_track_items: Vec<(String, bool)> = letters + // Count artists per letter for proportional sizing + let mut letter_counts: HashMap = HashMap::new(); + for a in artists.iter() { + let first = a.name.chars().next().unwrap_or('#').to_ascii_uppercase(); + let letter = if first.is_ascii_alphabetic() { + first + } else { + '#' + }; + *letter_counts.entry(letter).or_default() += 1; + } + let total_artists = artists.len().max(1); + + let all_letters: Vec = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); + let scroll_track_items: Vec<(String, f64)> = all_letters .iter() - .map(|&c| (c.to_string(), active_letters.contains(&c))) + .filter_map(|&c| { + let count = letter_counts.get(&c).copied().unwrap_or(0); + if count > 0 { + // Percentage of total artists — will be scaled to percentage of track + // by the effect that measures header/footer + Some((c.to_string(), count as f64 / total_artists as f64)) + } else { + None + } + }) .collect(); html! { @@ -80,29 +205,7 @@ pub fn library_page() -> Html { if artists.is_empty() {

{ "No artists in library. Use Search to add some!" }

} else { -
- { for scroll_track_items.iter().map(|(letter, active)| { - let letter_c = letter.clone(); - let active = *active; - let class = if active { "scroll-track-letter has-artists" } else { "scroll-track-letter" }; - let onclick = Callback::from(move |_: MouseEvent| { - if active { - if let Some(window) = web_sys::window() { - if let Some(doc) = window.document() { - if let Some(el) = doc.get_element_by_id(&format!("letter-{}", letter_c)) { - let _ = el.dyn_into::() - .map(|el| el.scroll_into_view()); - } - } - } - } - }); - html! { -
{ letter }
- } - })} -
- +
@@ -114,12 +217,14 @@ pub fn library_page() -> Html { { for artists.iter().map(|a| { - let anchor_id = first_of_letter.get(&a.id).map(|c| format!("letter-{c}")); + let anchor_id = + first_of_letter.get(&a.id).map(|c| format!("letter-{c}")); let artist_id = a.id; let artist_name = a.name.clone(); let artist_mbid = a.musicbrainz_id.clone(); let is_watched = a.total_watched > 0; - let is_fully_watched = a.enriched && a.total_watched >= a.total_items && a.total_items > 0; + let is_fully_watched = + a.enriched && a.total_watched >= a.total_items && a.total_items > 0; let is_monitored = a.monitored; let on_remove = { @@ -178,7 +283,9 @@ pub fn library_page() -> Html { let error = error.clone(); let fetch = fetch.clone(); wasm_bindgen_futures::spawn_local(async move { - match api::set_artist_monitored(artist_id, !is_monitored).await { + match api::set_artist_monitored(artist_id, !is_monitored) + .await + { Ok(_) => fetch.emit(()), Err(e) => error.set(Some(e.0)), } @@ -192,22 +299,29 @@ pub fn library_page() -> Html { } else { 0 }; - let owned_color = if a.total_owned >= a.total_watched && a.total_watched > 0 { - "var(--success)" - } else if a.total_owned > 0 { - "var(--warning)" - } else { - "var(--text-muted)" - }; - let owned_bar_style = format!("width:{owned_pct}%;background:{owned_color};"); + let owned_color = + if a.total_owned >= a.total_watched && a.total_watched > 0 { + "var(--success)" + } else if a.total_owned > 0 { + "var(--warning)" + } else { + "var(--text-muted)" + }; + let owned_bar_style = + format!("width:{owned_pct}%;background:{owned_color};"); let watched_pct = if a.total_items > 0 { (a.total_watched as f64 / a.total_items as f64 * 100.0) as u32 } else { 0 }; - let watched_color = if a.total_watched > 0 { "var(--accent)" } else { "var(--text-muted)" }; - let watched_bar_style = format!("width:{watched_pct}%;background:{watched_color};"); + let watched_color = if a.total_watched > 0 { + "var(--accent)" + } else { + "var(--text-muted)" + }; + let watched_bar_style = + format!("width:{watched_pct}%;background:{watched_color};"); html! { @@ -263,6 +377,33 @@ pub fn library_page() -> Html { })}
{ "Name" }
+
+
+ { for scroll_track_items.iter().map(|(letter, grow)| { + let letter_c = letter.clone(); + let data_grow = format!("{grow:.6}"); + let onclick = Callback::from(move |_: MouseEvent| { + if let Some(window) = web_sys::window() { + if let Some(doc) = window.document() { + if let Some(el) = doc.get_element_by_id(&format!("letter-{}", letter_c)) { + let rect = el.get_bounding_client_rect(); + let scroll_y = window.scroll_y().unwrap_or(0.0); + window.scroll_to_with_x_and_y( + 0.0, + rect.top() + scroll_y - 60.0, + ); + } + } + } + }); + html! { +
+ { letter } +
+ } + })} + +
} } diff --git a/frontend/style.css b/frontend/style.css index 90181b2..ee78b62 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -445,38 +445,35 @@ tr[draggable="true"]:active { cursor: grabbing; } .album-art-lg { width: 120px; height: 120px; } .album-header { flex-direction: column; } .artist-photo { width: 80px; height: 80px; } - .scroll-track { right: 2px; width: 14px; } - .scroll-track-letter { font-size: 0.5rem; } + .scroll-track { width: 18px; } + .scroll-track-letter { font-size: 0.7rem; } } -/* Alphabetical scroll track */ +/* Alphabetical scroll track — fixed to right edge next to browser scrollbar */ .scroll-track { position: fixed; - right: 8px; - top: 60px; - bottom: 20px; - width: 24px; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; + right: 18px; + top: 0; + bottom: 0; + width: 28px; z-index: 100; + pointer-events: none; + overflow: hidden; } .scroll-track-letter { - font-size: 1.4rem; + pointer-events: auto; + font-size: 1.2rem; font-weight: 600; - color: var(--text-muted); - cursor: default; - padding: 0 2px; - line-height: 1; - user-select: none; - opacity: 0.3; -} -.scroll-track-letter.has-artists { color: var(--text-secondary); cursor: pointer; - opacity: 1; + padding: 0 4px; + line-height: 1; + user-select: none; + display: flex; + align-items: flex-start; + justify-content: center; + overflow: hidden; } -.scroll-track-letter.has-artists:hover { +.scroll-track-letter:hover { color: var(--accent); }