Compare commits

...

1 Commits

Author SHA1 Message Date
Connor Johnstone 61225158f0 finally have a proper scroll bar 2026-04-01 12:25:59 -04:00
3 changed files with 202 additions and 64 deletions
+1 -1
View File
@@ -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"
+182 -41
View File
@@ -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::<web_sys::HtmlElement>().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::<web_sys::HtmlElement>() {
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::<web_sys::HtmlElement>() {
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::<web_sys::HtmlElement>() {
if let Some(grow_str) = el.dataset().get("grow") {
if let Ok(grow) = grow_str.parse::<f64>() {
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! { <div class="error">{ format!("Error: {err}") }</div> };
}
@@ -61,13 +166,33 @@ pub fn library_page() -> Html {
}
})
.collect();
let active_letters: HashSet<char> = first_of_letter.values().copied().collect();
// Build scroll track letter data
let letters: Vec<char> = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let scroll_track_items: Vec<(String, bool)> = letters
// Count artists per letter for proportional sizing
let mut letter_counts: HashMap<char, usize> = 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<char> = "#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() {
<p class="text-muted">{ "No artists in library. Use Search to add some!" }</p>
} else {
<div class="scroll-track">
{ 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::<web_sys::HtmlElement>()
.map(|el| el.scroll_into_view());
}
}
}
}
});
html! {
<div class={class} onclick={onclick}>{ letter }</div>
}
})}
</div>
<table>
<table id="library-table">
<thead>
<tr>
<th>{ "Name" }</th>
@@ -114,12 +217,14 @@ pub fn library_page() -> Html {
</thead>
<tbody>
{ 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! {
<tr id={anchor_id}>
@@ -263,6 +377,33 @@ pub fn library_page() -> Html {
})}
</tbody>
</table>
<div class="scroll-track">
<div id="scroll-track-header-spacer"></div>
{ 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! {
<div class="scroll-track-letter" data-grow={data_grow} {onclick}>
{ letter }
</div>
}
})}
<div id="scroll-track-footer-spacer"></div>
</div>
}
</div>
}
+19 -22
View File
@@ -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);
}