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"
|
serde_json = "1"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
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"
|
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 {
|
if let Some(ref err) = *error {
|
||||||
return html! { <div class="error">{ format!("Error: {err}") }</div> };
|
return html! { <div class="error">{ format!("Error: {err}") }</div> };
|
||||||
}
|
}
|
||||||
@@ -61,13 +166,33 @@ pub fn library_page() -> Html {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let active_letters: HashSet<char> = first_of_letter.values().copied().collect();
|
|
||||||
|
|
||||||
// Build scroll track letter data
|
// Count artists per letter for proportional sizing
|
||||||
let letters: Vec<char> = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
|
let mut letter_counts: HashMap<char, usize> = HashMap::new();
|
||||||
let scroll_track_items: Vec<(String, bool)> = letters
|
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()
|
.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();
|
.collect();
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
@@ -80,29 +205,7 @@ pub fn library_page() -> Html {
|
|||||||
if artists.is_empty() {
|
if artists.is_empty() {
|
||||||
<p class="text-muted">{ "No artists in library. Use Search to add some!" }</p>
|
<p class="text-muted">{ "No artists in library. Use Search to add some!" }</p>
|
||||||
} else {
|
} else {
|
||||||
<div class="scroll-track">
|
<table id="library-table">
|
||||||
{ 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>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{ "Name" }</th>
|
<th>{ "Name" }</th>
|
||||||
@@ -114,12 +217,14 @@ pub fn library_page() -> Html {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ for artists.iter().map(|a| {
|
{ 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_id = a.id;
|
||||||
let artist_name = a.name.clone();
|
let artist_name = a.name.clone();
|
||||||
let artist_mbid = a.musicbrainz_id.clone();
|
let artist_mbid = a.musicbrainz_id.clone();
|
||||||
let is_watched = a.total_watched > 0;
|
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 is_monitored = a.monitored;
|
||||||
|
|
||||||
let on_remove = {
|
let on_remove = {
|
||||||
@@ -178,7 +283,9 @@ pub fn library_page() -> Html {
|
|||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let fetch = fetch.clone();
|
let fetch = fetch.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
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(()),
|
Ok(_) => fetch.emit(()),
|
||||||
Err(e) => error.set(Some(e.0)),
|
Err(e) => error.set(Some(e.0)),
|
||||||
}
|
}
|
||||||
@@ -192,22 +299,29 @@ pub fn library_page() -> Html {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let owned_color = if a.total_owned >= a.total_watched && a.total_watched > 0 {
|
let owned_color =
|
||||||
"var(--success)"
|
if a.total_owned >= a.total_watched && a.total_watched > 0 {
|
||||||
} else if a.total_owned > 0 {
|
"var(--success)"
|
||||||
"var(--warning)"
|
} else if a.total_owned > 0 {
|
||||||
} else {
|
"var(--warning)"
|
||||||
"var(--text-muted)"
|
} else {
|
||||||
};
|
"var(--text-muted)"
|
||||||
let owned_bar_style = format!("width:{owned_pct}%;background:{owned_color};");
|
};
|
||||||
|
let owned_bar_style =
|
||||||
|
format!("width:{owned_pct}%;background:{owned_color};");
|
||||||
|
|
||||||
let watched_pct = if a.total_items > 0 {
|
let watched_pct = if a.total_items > 0 {
|
||||||
(a.total_watched as f64 / a.total_items as f64 * 100.0) as u32
|
(a.total_watched as f64 / a.total_items as f64 * 100.0) as u32
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let watched_color = if a.total_watched > 0 { "var(--accent)" } else { "var(--text-muted)" };
|
let watched_color = if a.total_watched > 0 {
|
||||||
let watched_bar_style = format!("width:{watched_pct}%;background:{watched_color};");
|
"var(--accent)"
|
||||||
|
} else {
|
||||||
|
"var(--text-muted)"
|
||||||
|
};
|
||||||
|
let watched_bar_style =
|
||||||
|
format!("width:{watched_pct}%;background:{watched_color};");
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<tr id={anchor_id}>
|
<tr id={anchor_id}>
|
||||||
@@ -263,6 +377,33 @@ pub fn library_page() -> Html {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-22
@@ -445,38 +445,35 @@ tr[draggable="true"]:active { cursor: grabbing; }
|
|||||||
.album-art-lg { width: 120px; height: 120px; }
|
.album-art-lg { width: 120px; height: 120px; }
|
||||||
.album-header { flex-direction: column; }
|
.album-header { flex-direction: column; }
|
||||||
.artist-photo { width: 80px; height: 80px; }
|
.artist-photo { width: 80px; height: 80px; }
|
||||||
.scroll-track { right: 2px; width: 14px; }
|
.scroll-track { width: 18px; }
|
||||||
.scroll-track-letter { font-size: 0.5rem; }
|
.scroll-track-letter { font-size: 0.7rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Alphabetical scroll track */
|
/* Alphabetical scroll track — fixed to right edge next to browser scrollbar */
|
||||||
.scroll-track {
|
.scroll-track {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 8px;
|
right: 18px;
|
||||||
top: 60px;
|
top: 0;
|
||||||
bottom: 20px;
|
bottom: 0;
|
||||||
width: 24px;
|
width: 28px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.scroll-track-letter {
|
.scroll-track-letter {
|
||||||
font-size: 1.4rem;
|
pointer-events: auto;
|
||||||
|
font-size: 1.2rem;
|
||||||
font-weight: 600;
|
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);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
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);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user