- music landing: diverse per-genre album sliders (online counts / offline wide-probe fallback) and home-screen library shortcuts - add ArtistLinks component and shared navigation/genreDiversity utils - player/playback-mode refinements across Rust and frontend
143 lines
4.7 KiB
Svelte
143 lines
4.7 KiB
Svelte
<!-- TRACES: UR-007 | DR-007 -->
|
|
<script lang="ts">
|
|
/**
|
|
* A vertical A-Z index strip for long, alphabetically-sorted lists.
|
|
* Tapping or dragging a letter jumps to the first item that starts with it.
|
|
*
|
|
* The parent owns the actual scrolling: it passes `availableLetters`
|
|
* (which letters have items) and an `onJump(letter)` callback.
|
|
*/
|
|
|
|
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
|
const HASH = "#"; // bucket for names starting with a digit/symbol
|
|
|
|
interface Props {
|
|
/** Uppercase letters (or "#") that currently have at least one item. */
|
|
availableLetters: Set<string>;
|
|
/** Called with the chosen letter when the user picks one. */
|
|
onJump: (letter: string) => void;
|
|
/**
|
|
* CSS length reserved at the bottom of the viewport for the bottom nav /
|
|
* mini-player bars. The strip stretches to fill the space between the top
|
|
* sticky offset and this gap, so it ends just above those bars.
|
|
*/
|
|
bottomGap?: string;
|
|
}
|
|
|
|
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
|
|
|
// The strip stretches from where it sits down to just above the bottom nav /
|
|
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
|
|
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
|
|
// top against that floor (clamped to non-negative) and update on scroll/resize
|
|
// so it never slides under the bars regardless of header or platform.
|
|
let container = $state<HTMLDivElement | null>(null);
|
|
let stripHeight = $state(0);
|
|
|
|
function measure() {
|
|
if (!container) return;
|
|
const top = container.getBoundingClientRect().top;
|
|
const floor = window.innerHeight - remToPx(bottomGap);
|
|
stripHeight = Math.max(0, floor - top);
|
|
}
|
|
|
|
function remToPx(len: string): number {
|
|
const n = parseFloat(len);
|
|
if (len.trim().endsWith("rem")) {
|
|
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
return n * root;
|
|
}
|
|
return n; // assume px otherwise
|
|
}
|
|
|
|
$effect(() => {
|
|
measure();
|
|
const scroller = container?.closest("main");
|
|
const ro = new ResizeObserver(measure);
|
|
if (scroller) ro.observe(scroller);
|
|
scroller?.addEventListener("scroll", measure, { passive: true });
|
|
window.addEventListener("resize", measure);
|
|
return () => {
|
|
ro.disconnect();
|
|
scroller?.removeEventListener("scroll", measure);
|
|
window.removeEventListener("resize", measure);
|
|
};
|
|
});
|
|
|
|
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
|
$effect(() => {
|
|
bottomGap;
|
|
measure();
|
|
});
|
|
|
|
const letters = $derived([HASH, ...ALPHABET]);
|
|
let activeLetter = $state<string | null>(null);
|
|
|
|
function jumpTo(letter: string) {
|
|
if (!availableLetters.has(letter)) return;
|
|
activeLetter = letter;
|
|
onJump(letter);
|
|
}
|
|
|
|
// Allow dragging a finger/mouse down the strip to scrub through letters.
|
|
function letterAt(clientY: number): string | null {
|
|
const el = document.elementFromPoint(
|
|
// x is supplied by the caller via the bound container; we read from the
|
|
// element under the pointer instead to stay layout-agnostic.
|
|
lastClientX,
|
|
clientY
|
|
);
|
|
const letter = el?.getAttribute?.("data-letter");
|
|
return letter ?? null;
|
|
}
|
|
|
|
let lastClientX = $state(0);
|
|
let isScrubbing = $state(false);
|
|
|
|
function handlePointerDown(e: PointerEvent) {
|
|
isScrubbing = true;
|
|
lastClientX = e.clientX;
|
|
const letter = letterAt(e.clientY);
|
|
if (letter) jumpTo(letter);
|
|
}
|
|
|
|
function handlePointerMove(e: PointerEvent) {
|
|
if (!isScrubbing) return;
|
|
lastClientX = e.clientX;
|
|
const letter = letterAt(e.clientY);
|
|
if (letter && letter !== activeLetter) jumpTo(letter);
|
|
}
|
|
|
|
function handlePointerUp() {
|
|
isScrubbing = false;
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
|
|
|
|
<div
|
|
bind:this={container}
|
|
class="flex flex-col items-center justify-between select-none touch-none py-1"
|
|
style="height: {stripHeight}px"
|
|
onpointerdown={handlePointerDown}
|
|
onpointermove={handlePointerMove}
|
|
role="navigation"
|
|
aria-label="Jump to letter"
|
|
>
|
|
{#each letters as letter (letter)}
|
|
{@const enabled = availableLetters.has(letter)}
|
|
<button
|
|
type="button"
|
|
data-letter={letter}
|
|
disabled={!enabled}
|
|
onclick={() => jumpTo(letter)}
|
|
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
|
|
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
|
|
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
|
|
aria-label={`Jump to ${letter}`}
|
|
>
|
|
{letter}
|
|
</button>
|
|
{/each}
|
|
</div>
|