docs(spec): add web UI library views design
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>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
# Web UI Library Views — Design Spec
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Status:** Design approved
|
||||
**Follows:** [Web UI Auth](2026-04-22-web-ui-auth-design.md) — 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/:id` page would show identical fields with no action affordances (the player plan adds playback; that's where click-to-play belongs).
|
||||
- No search UI. `/search` keeps 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:
|
||||
|
||||
```ts
|
||||
['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 with `variant="list" | "grid" | "album"` and `count` props.
|
||||
- `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
|
||||
|
||||
```ts
|
||||
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|newest` via `goto('?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's `total`).
|
||||
- **Body**: stacked `<ArtistRow>` items. Dark row background with lighter hover. Each row ~60px tall: 40×40 circle avatar (first letter), name, muted-color `N albums`, chevron.
|
||||
- **Footer**: full-width `<button class="Load more">`. Disabled + internal spinner while `isFetchingNextPage`. Becomes `<p>End of library</p>` when `hasNextPage === 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; subtitle `N 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 via `hover: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:SS` total 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 via `formatDuration(seconds)` — `mm:ss` when under an hour (`242 → "4:02"`), `h:mm:ss` at 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
|
||||
|
||||
```svelte
|
||||
<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_session` cookie 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_COVER` is a module-level data-URL constant exported from a small `covers.ts` helper.
|
||||
|
||||
## Loading/error patterns
|
||||
|
||||
**Delayed skeleton.** A small shared helper prevents flash-of-skeleton on cache hits:
|
||||
|
||||
```ts
|
||||
// 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:
|
||||
|
||||
```svelte
|
||||
<!-- 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 — with `formatDuration(242)` → `"4:02"`, `formatDuration(3723)` → `"1:02:03"`.
|
||||
- `LibrarySkeleton.test.ts` — variants `list | grid | album` each render a distinctive structure; `count` prop controls item count.
|
||||
- `ApiErrorBanner.test.ts` — shows `error.message` when present; falls back to "Something went wrong"; clicking the button calls `onRetry`.
|
||||
|
||||
**Page integration tests (mocked TanStack Query):**
|
||||
|
||||
Shared test util at `src/test-utils/query.ts`:
|
||||
|
||||
```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 === false` renders "End of library".
|
||||
- Sort `<select>` change triggers `goto('?sort=newest', ...)`.
|
||||
- Pending renders `<LibrarySkeleton variant="list">`; error renders `<ApiErrorBanner>`.
|
||||
- Empty state (total === 0) renders the empty-library message.
|
||||
- `artist.test.ts`
|
||||
- Renders `<AlbumCard>` for each album in `ArtistDetail.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.
|
||||
- `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.
|
||||
|
||||
**Manual browser check** (Plan Task N verification step):
|
||||
|
||||
1. `docker compose up --build -d` + login with seeded user.
|
||||
2. `/` shows the artists list; counts visible; "Load more" appends pages without reloading.
|
||||
3. Change sort dropdown → URL updates to `?sort=newest`; list reorders (oldest-added artists move to the bottom).
|
||||
4. Click an artist → albums grid renders with covers. Broken covers (if any) show the fallback glyph.
|
||||
5. Click an album → hero + track list. Back link returns to the artist (not the root).
|
||||
6. Visit `/albums/00000000-0000-0000-0000-000000000000` → "Album not found".
|
||||
7. Refresh `/artists/abc` while 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 test` in `web/`).
|
||||
- No backend changes required; this is pure frontend consumption of the existing `/api/artists`, `/api/artists/{id}`, `/api/albums/{id}`, `/api/albums/{id}/cover` endpoints.
|
||||
Reference in New Issue
Block a user