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>
255 lines
9.3 KiB
Svelte
255 lines
9.3 KiB
Svelte
<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}
|