finally have a proper scroll bar
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user