Files
minstrel/web/src/routes/library/genres/+page.svelte
T
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00

255 lines
9.3 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import { ChevronLeft } from 'lucide-svelte';
import {
createGenresQuery,
listAlbumsByGenre,
BROWSE_PAGE_SIZE,
type GenreCount
} from '#lib/api/browse.js';
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.js';
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));
});
// Count-first by default because that's what the server returns and it's the
// right default: the head of this list is genuinely where you're going. But a
// real library runs to several hundred genres with a long tail of one-offs
// (391 / ~3.7 per track on the operator's), and at that size "I know roughly
// what it's called" needs A-Z as much as filtering does.
//
// View state only, not a query parameter — same as `filter` above. Neither is
// worth making shareable, and putting one in the URL and not the other would
// be the inconsistent choice.
let sortMode = $state<'count' | 'name'>('count');
const visibleGenres = $derived.by(() => {
// Copy before sorting. With no filter applied `filteredGenres` IS the array
// held by the query cache, and Array.sort mutates in place — sorting it
// directly would reorder cached data for every other consumer.
const list = [...filteredGenres];
if (sortMode === 'name') {
// sensitivity 'base' so case and accents don't split neighbours apart.
return list.sort((a: GenreCount, b: GenreCount) =>
a.genre.localeCompare(b.genre, undefined, { sensitivity: 'base' })
);
}
return list; // server order: count DESC, then name
});
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-fg 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">
{#if filter.trim()}
{visibleGenres.length} of {genres.length} genres
{:else}
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags
{/if}
</p>
{/if}
</div>
{#if genres.length > 0}
<div class="flex flex-wrap items-center gap-2">
<QuickFilter bind:value={filter} placeholder="Filter genres" />
<label class="flex items-center gap-1.5 text-xs text-text-secondary">
Sort
<select
bind:value={sortMode}
aria-label="Sort genres"
class="rounded border border-border bg-surface px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
>
<option value="count">Most tracks</option>
<option value="name">A–Z</option>
</select>
</label>
</div>
{/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() && visibleGenres.length === 0}
<p class="text-text-secondary">
No genres match <span class="font-medium">'{filter.trim()}'</span>.
</p>
{:else}
<!-- Defaults to track count rather than alphabetical: raw tags carry a
long tail of one-offs, so A-Z would bury the handful of genres you
actually have a library's worth of. The Sort control lets you ask for
A-Z when you already know roughly what you're looking for. -->
<ul class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each visibleGenres 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="shrink-0 text-sm text-text-secondary">
{g.track_count}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{/if}