Three gaps in the frontend tooling, all in the package.json script surface.
1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.
Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
`lint`, `lint:fix`, `format`, `format:check` scripts.
The tree is error-clean (`npx eslint .` exits 0). Getting there needed
seven real one-line fixes (braced switch cases that leaked `const` across
arms, a useless regex escape, two `let`s that never change, a thrown Error
that dropped its `cause`, and two `// eslint-disable-next-line` comments
documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
that fires is set to `warn` with the reason written next to it in
eslint.config.js — notably ~94 dead bindings and `any` at the IPC
boundary. Those are real findings to drive to zero, not noise to delete.
`no-console` is OFF for now: a parallel change is moving all ~468 console
calls onto a logger facade, and turning the rule on today would collide
with it. eslint.config.js says so, and says to flip it to `error` once
that lands.
`prettier --write` is deliberately NOT run here — it would rewrite ~200
files and swamp every other diff in flight. The gate is available; the
sweep is a separate commit. Markdown and CI YAML are in .prettierignore
because both are hand-laid-out (and docs/traceability.md is generated).
2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
"Before Committing" list tells people to run it. It is now `vitest run`,
with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
scripts/test-all.sh drops the now-redundant `--run`, and
scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
them to a long-running vitest instead of the single-pass one.
3. The webdriverio e2e suite is deleted. It was last touched in January
("First working POC"), has never run since, and is not in CI — five
devDependencies and two scripts of pure decoration. Removes e2e/,
wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
devDeps, and the WebdriverIO block in .gitignore.
The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
146 lines
4.8 KiB
Svelte
146 lines
4.8 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(() => {
|
|
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
|
|
// idiom, not a stray expression.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
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>
|