Files
minstrel/web/src/routes/library/genres/+page.svelte
T
bvandeusen feb1c2eca8
test-web / test (push) Successful in 33s
feat(web): genre and year browse pages — #367
Client half of #367. Two new Library tabs, each an index plus a drill-down.

Genres are ordered by track count rather than alphabetically. Raw ID3 carries a
long tail of one-off tags, so alphabetical would bury the handful of genres you
actually have a library's worth of. Years are grouped into decades — a flat
list of every year in a decades-deep library is a wall of numbers, and the
decade is how people actually think about it.

## Selection travels in the query string, not the path

`?g=Rock%2FPop`, not `/library/genres/Rock%2FPop`. A slash-bearing genre cannot
survive a path segment — the server sees two segments, and a hard reload
wouldn't reconstruct it through the SPA fallback either. There's a test pinning
the encoded href and another pinning that the DECODED value reaches the API.

## Why these two pages don't use svelte-query for their lists

The indexes do — fetched once per mount, so static options suffice and the
cache survives bouncing in and out of a drill-down.

The drill-down lists deliberately don't. Their selection comes from the URL and
changes WITHOUT remounting the page, and this codebase has no
reactive-query-options pattern anywhere; inventing one here would be a larger
change than the feature justifies, and one I can't exercise locally. So they
use $effect keyed on the derived selection with an explicit Load more.

The stale-response guard is a plain `let`, not $state, and that's load-bearing:
as reactive state, reading the token inside the fetch path would make the
effect depend on its own writes. Its job is to discard a late response for a
previously selected genre instead of painting it over the current one.

## Also

Added the year filter to /library/albums' contract but NOT to that page's UI —
its infinite scroll is a svelte-query infinite query, and making it react to a
filter is the same reactive-options problem. The dedicated pages cover the
capability, which is the shape the task offered as its alternative.

Library tab bar's comment claims it mirrors Android's LibraryScreen. These two
tabs have no Android equivalent, so I noted that inline rather than leaving the
claim quietly false. Parity remains an open call.

Not yet done from #367's bullet list: genre/year quick-jump links on album and
artist detail. Year is free (AlbumRef already carries it) but genre is exposed
nowhere client-side — AlbumDetail is AlbumRef + tracks, and neither carries
genre — so it needs a small API addition. Following as its own commit.
2026-08-05 13:41:51 -04:00

211 lines
7.2 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '$lib/branding';
import { ChevronLeft } from 'lucide-svelte';
import {
createGenresQuery,
listAlbumsByGenre,
BROWSE_PAGE_SIZE,
type GenreCount
} from '$lib/api/browse';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import QuickFilter from '$lib/components/QuickFilter.svelte';
import type { AlbumRef } from '$lib/api/types';
const indexStore = createGenresQuery();
const index = $derived($indexStore);
const genres = $derived(index.data ?? []);
// Selection rides a query parameter rather than a route segment: "Rock/Pop"
// is a real ID3 tag and a slash cannot survive a path — neither the server's
// router nor an SPA-fallback reload would reconstruct it.
const selected = $derived(page.url.searchParams.get('g') ?? '');
let filter = $state('');
const filteredGenres = $derived.by(() => {
const q = filter.trim().toLowerCase();
if (!q) return genres;
return genres.filter((g: GenreCount) => g.genre.toLowerCase().includes(q));
});
let albums = $state<AlbumRef[]>([]);
let total = $state(0);
let loading = $state(false);
let failed = $state(false);
// Plain `let`, deliberately not $state: it's read inside the fetch path, and
// as reactive state that read would make this effect depend on its own
// writes. Its only job is to let a late response for a previous genre be
// discarded rather than painted over the current one.
let requestToken = 0;
$effect(() => {
const g = selected; // the only tracked read — reload when selection moves
void reload(g);
});
async function reload(genre: string) {
requestToken += 1;
albums = [];
total = 0;
failed = false;
if (!genre) return;
await fetchPage(genre, 0, requestToken);
}
async function fetchPage(genre: string, offset: number, token: number) {
loading = true;
try {
const p = await listAlbumsByGenre(genre, BROWSE_PAGE_SIZE, offset);
if (token !== requestToken) return; // selection moved on; drop it
albums = offset === 0 ? p.items : [...albums, ...p.items];
total = p.total;
} catch {
if (token === requestToken) failed = true;
} finally {
if (token === requestToken) loading = false;
}
}
function loadMore() {
void fetchPage(selected, albums.length, requestToken);
}
function genreHref(genre: string): string {
return `/library/genres?g=${encodeURIComponent(genre)}`;
}
</script>
<svelte:head>
<title>{pageTitle(selected ? `Library · ${selected}` : 'Library · Genres')}</title>
</svelte:head>
{#if selected}
<div class="space-y-4">
<header class="space-y-2">
<a
href="/library/genres"
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
>
<ChevronLeft size={14} aria-hidden="true" />
All genres
</a>
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
{#if !loading || albums.length > 0}
<p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'}
</p>
{/if}
</div>
</header>
{#if failed}
<p class="text-sm text-action-destructive">
Couldn't load albums for this genre.
<button
type="button"
class="underline hover:no-underline"
onclick={() => reload(selected)}>Try again</button
>
</p>
{:else if loading && albums.length === 0}
<p class="text-text-secondary">Loading…</p>
{:else if albums.length === 0}
<!-- Reachable when a genre exists in the index but its albums have since
been rescanned away. Not the multi-genre bug that made this whole
surface worth care — the server splits on both sides now. -->
<EmptyState
title="No albums for this genre"
hint="The library may have been rescanned since this list was built."
/>
{:else}
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
>
{#each albums as album (album.id)}
<AlbumCard {album} />
{/each}
</div>
{#if albums.length < total}
<div class="flex justify-center py-2">
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
disabled={loading}
onclick={loadMore}
>
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
</button>
</div>
{:else}
<p class="py-2 text-center text-sm text-text-secondary">End of genre</p>
{/if}
{/if}
</div>
{:else}
<div class="space-y-4">
<header class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">Genres</h1>
{#if !index.isPending && !index.isError}
<p class="text-sm text-text-secondary">
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags
</p>
{/if}
</div>
{#if genres.length > 0}
<QuickFilter bind:value={filter} placeholder="Filter genres" />
{/if}
</header>
{#if index.isError}
<ApiErrorBanner error={index.error} onRetry={index.refetch} />
{:else if index.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if genres.length === 0}
<EmptyState
title="No genres found"
hint="Genres come from the genre tag on your audio files. If your library is tagged but this is empty, try a rescan."
>
{#snippet actions()}
<a
href="/admin"
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
>
Open admin
</a>
{/snippet}
</EmptyState>
{:else if filter.trim() && filteredGenres.length === 0}
<p class="text-text-secondary">
No genres match <span class="font-medium">'{filter.trim()}'</span>.
</p>
{:else}
<!-- Ordered by track count, not alphabetically: raw tags carry a long
tail of one-offs, so alphabetical would bury the handful of genres
you actually have a library's worth of. -->
<ul class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredGenres as g (g.genre)}
<li>
<a
href={genreHref(g.genre)}
class="flex items-center justify-between gap-3 rounded-md border border-border
bg-surface px-3 py-2 hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent"
>
<span class="truncate text-text-primary">{g.genre}</span>
<span class="flex-shrink-0 text-sm text-text-secondary">
{g.track_count}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{/if}