test-web / test (push) Successful in 33s
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.
211 lines
7.2 KiB
Svelte
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}
|