Covers the three browse routes (/, /artists/:id, /albums/:id) on top
of the existing /api/artists, /api/artists/{id}, /api/albums/{id},
and /api/albums/{id}/cover endpoints. Uses TanStack Query's infinite
query for the artists list with Load-more pagination, reusable card
and row components, and a shared delayed-skeleton pattern for
cache-hit navigation feel.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
15 KiB
Web UI Library Views — Design Spec
Date: 2026-04-23 Status: Design approved Follows: Web UI Auth — this spec is the first data-consuming feature on top of the auth foundation.
Goal
Let an authenticated user browse the server's music library — artists, artist detail with albums, album detail with tracks — entirely in the SPA. After this lands, / is a real library index, deep links like /artists/abc-123 resolve to pages with data, and the cover_url/stream_url forward references emitted by the JSON API finally have consumers.
Non-goals
Explicit YAGNI — documented so they don't drift back in:
- No track detail page. Track metadata is already visible inline in the album view; a dedicated
/tracks/:idpage would show identical fields with no action affordances (the player plan adds playback; that's where click-to-play belongs). - No search UI.
/searchkeeps the auth plan's "Coming soon" placeholder. Search deserves its own sub-plan — different UX concerns (debouncing, faceted results, empty states). - No playback. All track rows are non-interactive;
<audio>never mounts. The player sub-plan wires everything. - No infinite scroll, virtualization, or hover-play previews. "Load more" is enough; we upgrade when the library actually feels slow.
- No "Recently Added" / recommendation surfaces. We don't have play signals yet.
- No theme switcher. Dark-only.
- No artist images / avatars from MusicBrainz or similar. Initial-letter circle is the placeholder forever (or until we add something better in a dedicated plan).
Architecture
Three routes, three queries. Route-per-view matches every widely-used music browser (Navidrome, Plex, Spotify web):
| Route | Page responsibility | Query |
|---|---|---|
/ |
Artists list with sort + pagination | createInfiniteQuery(['artists', {sort}]) |
/artists/:id |
Artist detail — name header, albums grid | createQuery(['artist', id]) |
/albums/:id |
Album detail — hero with cover, track list | createQuery(['album', id]) |
Modal-detail layouts (single-page with overlays) and three-pane split views were considered and rejected — they either break deep-linking or fight the existing sidebar Shell.
Data layer. TanStack Query owns all fetches and caching. A small web/src/lib/api/queries.ts module owns the query-key convention and exports createArtistsQuery, createArtistQuery, createAlbumQuery helpers so every page imports the same plumbing.
Query-key convention:
['artists', { sort: 'alpha' | 'newest' }] // infinite query; pageParam = offset
['artist', id] // single artist + nested albums
['album', id] // single album + nested tracks
Component decomposition. Each repeating visual unit is its own small Svelte component. Pages stay short; components stay testable in isolation.
ArtistRow— one row in the artists list. 40×40 avatar (first letter), name, album count, chevron. Whole row is an<a href="/artists/:id">.AlbumCard— one card in the albums grid. Cover image, title, year.<a href="/albums/:id">.TrackRow— one row in the album track table. Non-interactive until the player plan.LibrarySkeleton— shimmer placeholder withvariant="list" | "grid" | "album"andcountprops.ApiErrorBanner— retry-capable error card shared by all three pages.
Cache lifecycle. queryClient.clear() already runs on logout (auth plan), so library data from user A never leaks to user B. staleTime: 30_000 from the existing query-client defaults means tab-switching back within 30s avoids refetch; anything older is background-refreshed on mount.
File structure
New files under web/src/:
lib/
├── api/
│ ├── queries.ts # qk.* helpers + createArtistsQuery/createArtistQuery/createAlbumQuery
│ └── types.ts # ArtistRef, AlbumRef, TrackRef, ArtistDetail, AlbumDetail, Page<T>
├── components/
│ ├── ArtistRow.svelte
│ ├── ArtistRow.test.ts
│ ├── AlbumCard.svelte
│ ├── AlbumCard.test.ts
│ ├── TrackRow.svelte
│ ├── TrackRow.test.ts
│ ├── LibrarySkeleton.svelte
│ ├── LibrarySkeleton.test.ts
│ ├── ApiErrorBanner.svelte
│ └── ApiErrorBanner.test.ts
├── media/
│ ├── covers.ts # FALLBACK_COVER data URL + coverUrl(id) helper
│ └── duration.ts # formatDuration(seconds): '3:42' or '1:02:03'
└── utils/
└── useDelayed.svelte.ts # delayed-boolean rune helper for skeleton suppression
routes/
├── +page.svelte # Library root — artists list (REPLACE existing placeholder)
├── artists.test.ts # tests for the artists list page
├── artists/
│ └── [id]/
│ ├── +page.svelte
│ └── artist.test.ts
└── albums/
└── [id]/
├── +page.svelte
└── album.test.ts
test-utils/
└── query.ts # shared vi.mock helpers for @tanstack/svelte-query
Modified files:
| File | Change |
|---|---|
web/src/routes/+page.svelte |
Replace "Library" placeholder with the real artists list. |
Nothing touches the lib/api/client.ts transport — the api facade from the auth plan is sufficient. Nothing touches lib/auth/ — auth is a done surface.
queries.ts shape
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
import { api } from './client';
import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types';
export type ArtistSort = 'alpha' | 'newest';
const ARTIST_PAGE_SIZE = 50;
export const qk = {
artists: (sort: ArtistSort) => ['artists', { sort }] as const,
artist: (id: string) => ['artist', id] as const,
album: (id: string) => ['album', id] as const,
};
export function createArtistsQuery(sort: ArtistSort) {
return createInfiniteQuery({
queryKey: qk.artists(sort),
queryFn: ({ pageParam = 0 }) =>
api.get<Page<ArtistRef>>(
`/api/artists?sort=${sort}&limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`
),
initialPageParam: 0,
getNextPageParam: (last) => {
const loaded = last.offset + last.items.length;
return loaded >= last.total ? undefined : loaded;
},
});
}
export function createArtistQuery(id: string) {
return createQuery({
queryKey: qk.artist(id),
queryFn: () => api.get<ArtistDetail>(`/api/artists/${id}`),
});
}
export function createAlbumQuery(id: string) {
return createQuery({
queryKey: qk.album(id),
queryFn: () => api.get<AlbumDetail>(`/api/albums/${id}`),
});
}
types.ts — new file mirroring the backend internal/api/types.go shapes (ArtistRef, AlbumRef, TrackRef, ArtistDetail, AlbumDetail, Page<T>). Lives next to client.ts for discoverability.
Per-page layout
Artists list — /
- Top bar: left "Library" title (h1); right,
<select>bound to?sort=alpha|newestviagoto('?sort=...', {replaceState: true}). Query key re-derives from URL; TanStack Query fetches the new sort. Subtitle below: "1,247 artists" (pulled from the first page'stotal). - Body: stacked
<ArtistRow>items. Dark row background with lighter hover. Each row ~60px tall: 40×40 circle avatar (first letter), name, muted-colorN albums, chevron. - Footer: full-width
<button class="Load more">. Disabled + internal spinner whileisFetchingNextPage. Becomes<p>End of library</p>whenhasNextPage === false. - Empty state (total === 0): "No artists yet — scan a library folder via the server's config."
- Loading state (isPending):
<LibrarySkeleton variant="list" count={12} />. - Error state:
<ApiErrorBanner error={query.error} onRetry={query.refetch} />.
Artist detail — /artists/:id
- Top: back chevron link "← Library" that routes to
/. h1 with artist name; subtitleN albums. - Body: responsive grid of
<AlbumCard>. Tailwind breakpoints —grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5. Each card ~200px: cover (square), title, year below; hover scales the cover viahover:scale-[1.03] transition-transform. - Empty state: "This artist has no albums in the library." (Rare — backend only surfaces artists that have at least one album; included for defense in depth.)
- Not-found state (ApiError
{code: 'not_found', status: 404}): "Artist not found" with a back link. - Loading / error: standard skeleton + banner patterns.
Album detail — /albums/:id
- Top: back chevron link "← {artistName}" routing to
/artists/:artistId. - Hero: flex row on
md:+, column stack on mobile. Left/top: 240px square cover. Right/bottom: h1 title, artist name as a link to/artists/:artistId, year (if present),N tracks · H:MM:SStotal duration. - Body: track table. Columns:
#(32px right-aligned), title, duration (right-aligned). Track rows alternate subtle background tint. No play icons, no hover effects — player plan adds those. Duration renders viaformatDuration(seconds)—mm:sswhen under an hour (242 → "4:02"),h:mm:ssat or above (3723 → "1:02:03"). - Empty state: "This album has no tracks." (Rare; defense in depth.)
- Not-found state: "Album not found" with a back link to
/. - Loading: skeleton with cover-sized gray square + title/metadata bars + ~10 gray track rows.
Cover rendering
<img
src="/api/albums/{id}/cover"
alt=""
class="aspect-square w-full object-cover"
loading="lazy"
onerror={(e) => {
// fallback: inline SVG data URL with a music-note glyph on the surface color
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}}
/>
- Same-origin in dev (Vite proxy) and prod (embedded SPA) — the browser attaches the
minstrel_sessioncookie automatically. loading="lazy"means off-screen cards in the artist grid don't issue requests until scrolled.- Alt text is empty because the visible label (album title) is adjacent and screen readers read both; doubling the announcement is worse than skipping.
FALLBACK_COVERis a module-level data-URL constant exported from a smallcovers.tshelper.
Loading/error patterns
Delayed skeleton. A small shared helper prevents flash-of-skeleton on cache hits:
// lib/utils/useDelayed.svelte.ts
export function useDelayed(getValue: () => boolean, delayMs = 100) {
let delayed = $state(false);
let timeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
const v = getValue();
if (v) {
timeout = setTimeout(() => { delayed = true; }, delayMs);
} else {
if (timeout) clearTimeout(timeout);
delayed = false;
}
return () => { if (timeout) clearTimeout(timeout); };
});
return { get value() { return delayed; } };
}
Use: const showSkeleton = useDelayed(() => query.isPending); — skeleton renders only if isPending persists beyond 100ms. Cache-hit navigation feels instant; genuinely-loading views still show feedback.
Error banner. Single component reused across pages:
<!-- ApiErrorBanner.svelte -->
<script lang="ts">
import type { ApiError } from '$lib/api/client';
let { error, onRetry }: { error: unknown; onRetry: () => void } = $props();
const apiErr = error as ApiError | undefined;
const message = apiErr?.message ?? 'Something went wrong.';
</script>
<div role="alert" class="rounded border border-danger/40 bg-surface p-4">
<p class="text-text-primary">{message}</p>
<button type="button" class="mt-2 rounded bg-accent px-3 py-1 text-sm text-background" onclick={onRetry}>
Try again
</button>
</div>
404 branch. Detail pages check apiErr?.code === 'not_found' specifically and render a non-retryable "not found" card with a back link — retry buttons on 404s just confuse users.
Testing
Component unit tests (Vitest + Testing Library + jest-dom):
ArtistRow.test.ts— renders initial ("alice"→"A"), name,12 albums, href/artists/{id}.AlbumCard.test.ts— renders<img src="/api/albums/{id}/cover">, title, year, href/albums/{id}. Broken-cover handler swaps src to the fallback data URL.TrackRow.test.ts— renders#(track number), title, duration — withformatDuration(242)→"4:02",formatDuration(3723)→"1:02:03".LibrarySkeleton.test.ts— variantslist | grid | albumeach render a distinctive structure;countprop controls item count.ApiErrorBanner.test.ts— showserror.messagewhen present; falls back to "Something went wrong"; clicking the button callsonRetry.
Page integration tests (mocked TanStack Query):
Shared test util at src/test-utils/query.ts:
// Returns a minimal synthetic infinite-query store.
export function mockInfiniteQuery<T>(opts: {
pages?: T[];
isPending?: boolean;
isError?: boolean;
error?: unknown;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
}) { /* ... */ }
export function mockQuery<T>(opts: {
data?: T;
isPending?: boolean;
isError?: boolean;
error?: unknown;
refetch?: () => void;
}) { /* ... */ }
Then:
artists.test.ts- Renders
<ArtistRow>for each artist in mocked pages. - Clicking "Load more" calls
fetchNextPage. hasNextPage === falserenders "End of library".- Sort
<select>change triggersgoto('?sort=newest', ...). - Pending renders
<LibrarySkeleton variant="list">; error renders<ApiErrorBanner>. - Empty state (total === 0) renders the empty-library message.
- Renders
artist.test.ts- Renders
<AlbumCard>for each album inArtistDetail.albums. - Back link
href="/"with text "← Library". - 404 error renders "Artist not found" with back link and NO retry button.
- Pending renders skeleton; non-404 error renders banner with retry.
- Renders
album.test.ts- Renders cover (src pointing at
/api/albums/:id/cover), title, artist link, year, metadata summary. - Renders
<TrackRow>for each track. - Back link
href="/artists/:artistId"with text"← {artistName}". - 404 error renders "Album not found" with back link.
- Renders cover (src pointing at
Manual browser check (Plan Task N verification step):
docker compose up --build -d+ login with seeded user./shows the artists list; counts visible; "Load more" appends pages without reloading.- Change sort dropdown → URL updates to
?sort=newest; list reorders (oldest-added artists move to the bottom). - Click an artist → albums grid renders with covers. Broken covers (if any) show the fallback glyph.
- Click an album → hero + track list. Back link returns to the artist (not the root).
- Visit
/albums/00000000-0000-0000-0000-000000000000→ "Album not found". - Refresh
/artists/abcwhile authenticated → no flash of login; data re-fetches and renders.
Success criteria
- A user can start at
/, drill into any artist, then into any album, then back — all via deep-linkable URLs. - Cover images render; broken covers fall back gracefully.
- "Load more" extends the artist list; sort dropdown changes order and reflects in the URL.
- 404s (not-found artist / album) render a specific not-found card, not a retry banner.
- Cache-hit navigation (e.g., pressing Back) is instant — no skeleton flash.
- All unit and page tests pass in CI (
npm testinweb/). - No backend changes required; this is pure frontend consumption of the existing
/api/artists,/api/artists/{id},/api/albums/{id},/api/albums/{id}/coverendpoints.