Merge pull request 'feat: web UI library views — artists / artist detail / album detail' (#18) from dev into main
This commit was merged in pull request #18.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { qk } from './queries';
|
||||||
|
|
||||||
|
describe('qk key generators', () => {
|
||||||
|
test('artists key includes sort discriminator', () => {
|
||||||
|
expect(qk.artists('alpha')).toEqual(['artists', { sort: 'alpha' }]);
|
||||||
|
expect(qk.artists('newest')).toEqual(['artists', { sort: 'newest' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('artist key tuple', () => {
|
||||||
|
expect(qk.artist('abc')).toEqual(['artist', 'abc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('album key tuple', () => {
|
||||||
|
expect(qk.album('xyz')).toEqual(['album', 'xyz']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
|
||||||
|
import { api } from './client';
|
||||||
|
import type { ArtistRef, ArtistDetail, AlbumDetail, Page } from './types';
|
||||||
|
|
||||||
|
export type ArtistSort = 'alpha' | 'newest';
|
||||||
|
export 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}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Mirrors internal/api/types.go. Kept minimal — only the shapes the SPA
|
||||||
|
// actually reads today (no LoginRequest/LoginResponse here; those live in
|
||||||
|
// client.ts alongside the auth plumbing).
|
||||||
|
|
||||||
|
export type Page<T> = {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArtistRef = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
album_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlbumRef = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist_id: string;
|
||||||
|
artist_name: string;
|
||||||
|
year?: number;
|
||||||
|
track_count: number;
|
||||||
|
duration_sec: number;
|
||||||
|
cover_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackRef = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
album_id: string;
|
||||||
|
album_title: string;
|
||||||
|
artist_id: string;
|
||||||
|
artist_name: string;
|
||||||
|
track_number?: number;
|
||||||
|
disc_number?: number;
|
||||||
|
duration_sec: number;
|
||||||
|
stream_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArtistDetail = ArtistRef & {
|
||||||
|
albums: AlbumRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlbumDetail = AlbumRef & {
|
||||||
|
tracks: TrackRef[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AlbumRef } from '$lib/api/types';
|
||||||
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
|
|
||||||
|
let { album }: { album: AlbumRef } = $props();
|
||||||
|
|
||||||
|
function onImgError(e: Event) {
|
||||||
|
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/albums/${album.id}`}
|
||||||
|
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={album.cover_url}
|
||||||
|
alt=""
|
||||||
|
class="aspect-square w-full rounded object-cover transition-transform group-hover:scale-[1.03]"
|
||||||
|
loading="lazy"
|
||||||
|
onerror={onImgError}
|
||||||
|
/>
|
||||||
|
<div class="mt-2 truncate text-sm font-medium">{album.title}</div>
|
||||||
|
{#if album.year}
|
||||||
|
<div class="text-xs text-text-secondary">{album.year}</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import AlbumCard from './AlbumCard.svelte';
|
||||||
|
import type { AlbumRef } from '$lib/api/types';
|
||||||
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
|
|
||||||
|
const album: AlbumRef = {
|
||||||
|
id: 'xyz',
|
||||||
|
title: 'Kind of Blue',
|
||||||
|
artist_id: 'm-davis',
|
||||||
|
artist_name: 'Miles Davis',
|
||||||
|
year: 1959,
|
||||||
|
track_count: 5,
|
||||||
|
duration_sec: 2630,
|
||||||
|
cover_url: '/api/albums/xyz/cover',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AlbumCard', () => {
|
||||||
|
test('renders cover, title, year inside a link to /albums/:id', () => {
|
||||||
|
const { container } = render(AlbumCard, { props: { album } });
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/albums/xyz');
|
||||||
|
expect(link).toHaveTextContent('Kind of Blue');
|
||||||
|
expect(link).toHaveTextContent('1959');
|
||||||
|
|
||||||
|
const img = container.querySelector('img') as HTMLImageElement;
|
||||||
|
expect(img.src).toContain('/api/albums/xyz/cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('year is omitted when not present', () => {
|
||||||
|
render(AlbumCard, { props: { album: { ...album, year: undefined } } });
|
||||||
|
// No crash; no year text.
|
||||||
|
expect(screen.queryByText(/1959/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broken cover falls back to FALLBACK_COVER data URL', async () => {
|
||||||
|
const { container } = render(AlbumCard, { props: { album } });
|
||||||
|
const img = container.querySelector('img') as HTMLImageElement;
|
||||||
|
await fireEvent.error(img);
|
||||||
|
expect(img.src).toBe(FALLBACK_COVER);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ApiError } from '$lib/api/client';
|
||||||
|
|
||||||
|
let {
|
||||||
|
error,
|
||||||
|
onRetry
|
||||||
|
}: { error: unknown; onRetry: () => void } = $props();
|
||||||
|
|
||||||
|
const apiErr = $derived(error as ApiError | undefined);
|
||||||
|
const message = $derived(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>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import ApiErrorBanner from './ApiErrorBanner.svelte';
|
||||||
|
|
||||||
|
describe('ApiErrorBanner', () => {
|
||||||
|
test('renders error.message when present', () => {
|
||||||
|
render(ApiErrorBanner, {
|
||||||
|
props: {
|
||||||
|
error: { code: 'server_error', message: 'database down', status: 500 },
|
||||||
|
onRetry: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('database down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to generic text when error lacks message', () => {
|
||||||
|
render(ApiErrorBanner, { props: { error: undefined, onRetry: () => {} } });
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the button calls onRetry', async () => {
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
render(ApiErrorBanner, {
|
||||||
|
props: {
|
||||||
|
error: { code: 'server_error', message: 'x', status: 500 },
|
||||||
|
onRetry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ArtistRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
let { artist }: { artist: ArtistRef } = $props();
|
||||||
|
|
||||||
|
const initial = $derived(artist.name ? artist.name.charAt(0).toUpperCase() : '');
|
||||||
|
const countLabel = $derived(artist.album_count === 1 ? '1 album' : `${artist.album_count} albums`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/artists/${artist.id}`}
|
||||||
|
class="flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 truncate">{artist.name}</span>
|
||||||
|
<span class="text-sm text-text-secondary">{countLabel}</span>
|
||||||
|
<span class="text-text-secondary" aria-hidden="true">›</span>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import ArtistRow from './ArtistRow.svelte';
|
||||||
|
import type { ArtistRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 };
|
||||||
|
|
||||||
|
describe('ArtistRow', () => {
|
||||||
|
test('renders initial, name, album count inside a link to /artists/:id', () => {
|
||||||
|
render(ArtistRow, { props: { artist } });
|
||||||
|
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/artists/abc');
|
||||||
|
expect(link).toHaveTextContent('alice');
|
||||||
|
expect(link).toHaveTextContent('12 albums');
|
||||||
|
expect(link).toHaveTextContent('A'); // initial letter
|
||||||
|
});
|
||||||
|
|
||||||
|
test('singular "1 album" when album_count is 1', () => {
|
||||||
|
render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } });
|
||||||
|
expect(screen.getByRole('link')).toHaveTextContent('1 album');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty name still renders a safe initial', () => {
|
||||||
|
render(ArtistRow, { props: { artist: { ...artist, name: '' } } });
|
||||||
|
// No crash; initial container present but empty or a placeholder char.
|
||||||
|
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Variant = 'list' | 'grid' | 'album';
|
||||||
|
let { variant, count = 12 }: { variant: Variant; count?: number } = $props();
|
||||||
|
|
||||||
|
// Common shimmer class applied to every placeholder block.
|
||||||
|
const shimmer = 'animate-pulse bg-surface';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if variant === 'list'}
|
||||||
|
<div data-testid="skeleton-list" class="divide-y divide-border">
|
||||||
|
{#each Array.from({ length: count }) as _, i (i)}
|
||||||
|
<div data-skeleton-row class="flex items-center gap-3 px-3 py-3">
|
||||||
|
<span class="h-10 w-10 rounded-full {shimmer}"></span>
|
||||||
|
<span class="h-4 flex-1 rounded {shimmer}"></span>
|
||||||
|
<span class="h-3 w-20 rounded {shimmer}"></span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if variant === 'grid'}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-grid"
|
||||||
|
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
||||||
|
>
|
||||||
|
{#each Array.from({ length: count }) as _, i (i)}
|
||||||
|
<div data-skeleton-card>
|
||||||
|
<div class="aspect-square w-full rounded {shimmer}"></div>
|
||||||
|
<div class="mt-2 h-4 w-3/4 rounded {shimmer}"></div>
|
||||||
|
<div class="mt-1 h-3 w-1/3 rounded {shimmer}"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div data-testid="skeleton-album" class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||||
|
<div data-testid="skeleton-album-cover" class="h-60 w-60 rounded {shimmer}"></div>
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div class="h-8 w-3/4 rounded {shimmer}"></div>
|
||||||
|
<div class="h-4 w-1/3 rounded {shimmer}"></div>
|
||||||
|
<div class="h-4 w-1/4 rounded {shimmer}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each Array.from({ length: 10 }) as _, i (i)}
|
||||||
|
<div class="h-6 w-full rounded {shimmer}"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import LibrarySkeleton from './LibrarySkeleton.svelte';
|
||||||
|
|
||||||
|
describe('LibrarySkeleton', () => {
|
||||||
|
test('variant="list" renders count placeholder rows', () => {
|
||||||
|
render(LibrarySkeleton, { props: { variant: 'list', count: 5 } });
|
||||||
|
const container = screen.getByTestId('skeleton-list');
|
||||||
|
expect(container.querySelectorAll('[data-skeleton-row]').length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('variant="grid" renders count placeholder cards', () => {
|
||||||
|
render(LibrarySkeleton, { props: { variant: 'grid', count: 6 } });
|
||||||
|
const container = screen.getByTestId('skeleton-grid');
|
||||||
|
expect(container.querySelectorAll('[data-skeleton-card]').length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('variant="album" renders a hero placeholder with cover block + bars', () => {
|
||||||
|
render(LibrarySkeleton, { props: { variant: 'album' } });
|
||||||
|
expect(screen.getByTestId('skeleton-album')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('skeleton-album-cover')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
import { formatDuration } from '$lib/media/duration';
|
||||||
|
|
||||||
|
let { track }: { track: TrackRef } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[32px_1fr_auto] items-center gap-4 px-3 py-2 text-sm odd:bg-surface/50">
|
||||||
|
<span class="text-right tabular-nums text-text-secondary">
|
||||||
|
{track.track_number ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{track.title}</span>
|
||||||
|
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import TrackRow from './TrackRow.svelte';
|
||||||
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
const track: TrackRef = {
|
||||||
|
id: 't1',
|
||||||
|
title: 'So What',
|
||||||
|
album_id: 'xyz',
|
||||||
|
album_title: 'Kind of Blue',
|
||||||
|
artist_id: 'm-davis',
|
||||||
|
artist_name: 'Miles Davis',
|
||||||
|
track_number: 1,
|
||||||
|
disc_number: 1,
|
||||||
|
duration_sec: 545,
|
||||||
|
stream_url: '/api/tracks/t1/stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TrackRow', () => {
|
||||||
|
test('renders track number, title, formatted duration', () => {
|
||||||
|
render(TrackRow, { props: { track } });
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('So What')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('9:05')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('track number falls back to dash when missing', () => {
|
||||||
|
render(TrackRow, { props: { track: { ...track, track_number: undefined } } });
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Fallback cover — an inline SVG data URL showing a music-note glyph on the
|
||||||
|
// surface color. Used by AlbumCard's onerror handler when the real cover
|
||||||
|
// fails (e.g. scanner didn't pick up an embedded image and there's no
|
||||||
|
// sidecar). Data URL avoids an extra HTTP round-trip.
|
||||||
|
export const FALLBACK_COVER =
|
||||||
|
'data:image/svg+xml;utf8,' +
|
||||||
|
encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" fill="#1f1f23"/>
|
||||||
|
<path d="M40 30v35a7 7 0 1 1-7-7 7 7 0 0 1 3 .7V35h22v8h-18z" fill="#6b6b70"/>
|
||||||
|
</svg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
export function coverUrl(albumId: string): string {
|
||||||
|
return `/api/albums/${albumId}/cover`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { formatDuration } from './duration';
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
test('under a minute renders 0:SS', () => {
|
||||||
|
expect(formatDuration(42)).toBe('0:42');
|
||||||
|
expect(formatDuration(5)).toBe('0:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('under an hour renders M:SS', () => {
|
||||||
|
expect(formatDuration(242)).toBe('4:02');
|
||||||
|
expect(formatDuration(600)).toBe('10:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at or above an hour renders H:MM:SS', () => {
|
||||||
|
expect(formatDuration(3600)).toBe('1:00:00');
|
||||||
|
expect(formatDuration(3723)).toBe('1:02:03');
|
||||||
|
expect(formatDuration(36000)).toBe('10:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero renders 0:00', () => {
|
||||||
|
expect(formatDuration(0)).toBe('0:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative values clamp to 0:00', () => {
|
||||||
|
expect(formatDuration(-1)).toBe('0:00');
|
||||||
|
expect(formatDuration(-100)).toBe('0:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fractional seconds floor', () => {
|
||||||
|
expect(formatDuration(61.9)).toBe('1:01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Formats a non-negative number of seconds as `mm:ss` when under an hour,
|
||||||
|
// `h:mm:ss` otherwise. Negative values clamp to zero (callers shouldn't
|
||||||
|
// pass them, but the UI shouldn't crash if they do).
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const total = Math.max(0, Math.floor(seconds));
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
const ss = String(s).padStart(2, '0');
|
||||||
|
if (h > 0) {
|
||||||
|
const mm = String(m).padStart(2, '0');
|
||||||
|
return `${h}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
return `${m}:${ss}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import { useDelayed } from './useDelayed.svelte';
|
||||||
|
|
||||||
|
describe('useDelayed', () => {
|
||||||
|
test('value stays false until delay elapses while source is true', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
let source = $state(false);
|
||||||
|
let delayed!: { readonly value: boolean };
|
||||||
|
const cleanup = $effect.root(() => {
|
||||||
|
delayed = useDelayed(() => source, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(delayed.value).toBe(false);
|
||||||
|
|
||||||
|
source = true;
|
||||||
|
flushSync();
|
||||||
|
expect(delayed.value).toBe(false);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(delayed.value).toBe(false);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60);
|
||||||
|
expect(delayed.value).toBe(true);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source flipping false before delay cancels the pending timeout', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
let source = $state(false);
|
||||||
|
let delayed!: { readonly value: boolean };
|
||||||
|
const cleanup = $effect.root(() => {
|
||||||
|
delayed = useDelayed(() => source, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
source = true;
|
||||||
|
flushSync();
|
||||||
|
vi.advanceTimersByTime(40);
|
||||||
|
source = false;
|
||||||
|
flushSync();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(delayed.value).toBe(false);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('value resets to false immediately when source becomes false', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
let source = $state(true);
|
||||||
|
let delayed!: { readonly value: boolean };
|
||||||
|
const cleanup = $effect.root(() => {
|
||||||
|
delayed = useDelayed(() => source, 100);
|
||||||
|
});
|
||||||
|
flushSync(); // run the initial $effect so the setTimeout is scheduled
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(delayed.value).toBe(true);
|
||||||
|
|
||||||
|
source = false;
|
||||||
|
flushSync();
|
||||||
|
expect(delayed.value).toBe(false);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Returns a rune-wrapped boolean that mirrors the source getter, but only
|
||||||
|
// flips to true after the source has been true for delayMs continuously.
|
||||||
|
// Flips back to false immediately when the source becomes false.
|
||||||
|
//
|
||||||
|
// Used to suppress flash-of-skeleton on cache-hit navigation: skeletons
|
||||||
|
// only render for loads that take longer than ~100ms.
|
||||||
|
export function useDelayed(
|
||||||
|
source: () => boolean,
|
||||||
|
delayMs = 100
|
||||||
|
): { readonly value: boolean } {
|
||||||
|
let delayed = $state(false);
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const v = source();
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
}
|
||||||
|
if (v) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
delayed = true;
|
||||||
|
timeout = null;
|
||||||
|
}, delayMs);
|
||||||
|
} else {
|
||||||
|
delayed = false;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { get value() { return delayed; } };
|
||||||
|
}
|
||||||
@@ -1,2 +1,83 @@
|
|||||||
<h1 class="text-2xl font-semibold">Library</h1>
|
<script lang="ts">
|
||||||
<p class="mt-2 text-text-secondary">Library views land in a subsequent plan.</p>
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { createArtistsQuery, type ArtistSort } from '$lib/api/queries';
|
||||||
|
import ArtistRow from '$lib/components/ArtistRow.svelte';
|
||||||
|
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
||||||
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
|
import { useDelayed } from '$lib/utils/useDelayed.svelte';
|
||||||
|
|
||||||
|
const sort: ArtistSort = $derived(
|
||||||
|
(page.url.searchParams.get('sort') as ArtistSort) === 'newest' ? 'newest' : 'alpha'
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryStore = $derived(createArtistsQuery(sort));
|
||||||
|
const query = $derived($queryStore);
|
||||||
|
|
||||||
|
// Flattened artist list from all loaded pages.
|
||||||
|
const artists = $derived(
|
||||||
|
query.data?.pages?.flatMap((p) => p.items) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
|
||||||
|
|
||||||
|
const showSkeleton = useDelayed(() => query.isPending);
|
||||||
|
|
||||||
|
function onSortChange(e: Event) {
|
||||||
|
const value = (e.currentTarget as HTMLSelectElement).value as ArtistSort;
|
||||||
|
goto(`?sort=${value}`, { replaceState: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Library</h1>
|
||||||
|
{#if !query.isPending && !query.isError}
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
{total} {total === 1 ? 'artist' : 'artists'}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-text-secondary">Sort</span>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onchange={onSortChange}
|
||||||
|
class="rounded border border-border bg-surface px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="alpha">A–Z</option>
|
||||||
|
<option value="newest">Newest</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if query.isError}
|
||||||
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||||
|
{:else if showSkeleton.value && artists.length === 0}
|
||||||
|
<LibrarySkeleton variant="list" count={12} />
|
||||||
|
{:else if !query.isPending && total === 0}
|
||||||
|
<p class="text-text-secondary">
|
||||||
|
No artists yet — scan a library folder via the server's config.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
{#each artists as a (a.id)}
|
||||||
|
<ArtistRow artist={a} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if query.hasNextPage}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||||||
|
disabled={query.isFetchingNextPage}
|
||||||
|
onclick={() => query.fetchNextPage()}
|
||||||
|
>
|
||||||
|
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
{:else if artists.length > 0}
|
||||||
|
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { createAlbumQuery } from '$lib/api/queries';
|
||||||
|
import TrackRow from '$lib/components/TrackRow.svelte';
|
||||||
|
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
||||||
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
|
import { useDelayed } from '$lib/utils/useDelayed.svelte';
|
||||||
|
import { formatDuration } from '$lib/media/duration';
|
||||||
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
|
import type { ApiError } from '$lib/api/client';
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? '');
|
||||||
|
const queryStore = $derived(createAlbumQuery(id));
|
||||||
|
const query = $derived($queryStore);
|
||||||
|
const showSkeleton = useDelayed(() => query.isPending);
|
||||||
|
|
||||||
|
const notFound = $derived(
|
||||||
|
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
|
||||||
|
);
|
||||||
|
|
||||||
|
function onCoverError(e: Event) {
|
||||||
|
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#if notFound}
|
||||||
|
<div role="alert" class="rounded border border-border bg-surface p-4">
|
||||||
|
<p>Album not found.</p>
|
||||||
|
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
|
||||||
|
</div>
|
||||||
|
{:else if query.isError}
|
||||||
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||||
|
{:else if showSkeleton.value && !query.data}
|
||||||
|
<LibrarySkeleton variant="album" />
|
||||||
|
{:else if query.data}
|
||||||
|
{@const album = query.data}
|
||||||
|
<a
|
||||||
|
href={`/artists/${album.artist_id}`}
|
||||||
|
class="text-sm text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
← {album.artist_name}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<header class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||||
|
<img
|
||||||
|
src={album.cover_url}
|
||||||
|
alt={album.title}
|
||||||
|
class="h-60 w-60 rounded object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onerror={onCoverError}
|
||||||
|
/>
|
||||||
|
<div class="flex-1 space-y-1">
|
||||||
|
<h1 class="text-3xl font-semibold">{album.title}</h1>
|
||||||
|
<p>
|
||||||
|
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
||||||
|
</p>
|
||||||
|
{#if album.year}
|
||||||
|
<p class="text-sm text-text-secondary">{album.year}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
||||||
|
· {formatDuration(album.duration_sec)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if album.tracks.length === 0}
|
||||||
|
<p class="text-text-secondary">This album has no tracks.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded border border-border">
|
||||||
|
{#each album.tracks as track (track.id)}
|
||||||
|
<TrackRow {track} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
|
import type { AlbumDetail, TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({ pageParams: { id: 'xyz' } as Record<string, string> }));
|
||||||
|
|
||||||
|
vi.mock('$app/state', () => ({
|
||||||
|
page: {
|
||||||
|
get params() { return state.pageParams; },
|
||||||
|
get url() { return new URL('http://localhost/albums/' + state.pageParams.id); }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/api/queries', () => ({
|
||||||
|
qk: { album: (id: string) => ['album', id] },
|
||||||
|
createAlbumQuery: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AlbumPage from './+page.svelte';
|
||||||
|
import { createAlbumQuery } from '$lib/api/queries';
|
||||||
|
|
||||||
|
function track(id: string, title: string, number: number, dur: number): TrackRef {
|
||||||
|
return {
|
||||||
|
id, title,
|
||||||
|
album_id: 'xyz', album_title: 'Kind of Blue',
|
||||||
|
artist_id: 'md', artist_name: 'Miles Davis',
|
||||||
|
track_number: number, disc_number: 1,
|
||||||
|
duration_sec: dur,
|
||||||
|
stream_url: `/api/tracks/${id}/stream`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.pageParams = { id: 'xyz' };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('album detail page', () => {
|
||||||
|
test('renders hero metadata + one TrackRow per track', () => {
|
||||||
|
const detail: AlbumDetail = {
|
||||||
|
id: 'xyz', title: 'Kind of Blue',
|
||||||
|
artist_id: 'md', artist_name: 'Miles Davis',
|
||||||
|
year: 1959, track_count: 2, duration_sec: 544 + 565,
|
||||||
|
cover_url: '/api/albums/xyz/cover',
|
||||||
|
tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)]
|
||||||
|
};
|
||||||
|
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||||
|
render(AlbumPage);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Kind of Blue');
|
||||||
|
// Two links point at /artists/md: the back chevron and the inline hero link.
|
||||||
|
// getAllByRole returns both; both should target the artist page.
|
||||||
|
const artistLinks = screen.getAllByRole('link', { name: /Miles Davis/ });
|
||||||
|
expect(artistLinks.length).toBeGreaterThanOrEqual(1);
|
||||||
|
artistLinks.forEach((l) => expect(l).toHaveAttribute('href', '/artists/md'));
|
||||||
|
expect(screen.getByText(/1959/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/2 tracks/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('So What')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Freddie Freeloader')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const img = screen.getByRole('img') as HTMLImageElement;
|
||||||
|
expect(img.src).toContain('/api/albums/xyz/cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('back link points to /artists/:artistId with the artist name', () => {
|
||||||
|
const detail: AlbumDetail = {
|
||||||
|
id: 'xyz', title: 'T',
|
||||||
|
artist_id: 'md', artist_name: 'Miles Davis',
|
||||||
|
track_count: 0, duration_sec: 0,
|
||||||
|
cover_url: '/api/albums/xyz/cover',
|
||||||
|
tracks: []
|
||||||
|
};
|
||||||
|
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||||
|
render(AlbumPage);
|
||||||
|
// Back chevron specifically (disambiguated from the inline artist link by
|
||||||
|
// the leading arrow character).
|
||||||
|
const back = screen.getByRole('link', { name: /^← Miles Davis/ });
|
||||||
|
expect(back).toHaveAttribute('href', '/artists/md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('404 renders non-retryable "Album not found" with back link', () => {
|
||||||
|
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
|
isError: true,
|
||||||
|
error: { code: 'not_found', message: 'nope', status: 404 }
|
||||||
|
}));
|
||||||
|
render(AlbumPage);
|
||||||
|
expect(screen.getByText(/album not found/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-404 error renders the retry banner', () => {
|
||||||
|
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
|
isError: true,
|
||||||
|
error: { code: 'server_error', message: 'boom', status: 500 }
|
||||||
|
}));
|
||||||
|
render(AlbumPage);
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('boom');
|
||||||
|
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending (after delay) renders the album skeleton', () => {
|
||||||
|
(createAlbumQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ isPending: true }));
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
render(AlbumPage);
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
flushSync();
|
||||||
|
expect(screen.getByTestId('skeleton-album')).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import { mockInfiniteQuery } from '../test-utils/query';
|
||||||
|
import type { ArtistRef, Page } from '$lib/api/types';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') }));
|
||||||
|
|
||||||
|
vi.mock('$app/state', () => ({
|
||||||
|
page: { get url() { return state.pageUrl; } }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
goto: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/api/queries', () => ({
|
||||||
|
qk: { artists: (sort: string) => ['artists', { sort }] },
|
||||||
|
createArtistsQuery: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ArtistsPage from './+page.svelte';
|
||||||
|
import { createArtistsQuery } from '$lib/api/queries';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
|
||||||
|
return { items, total, offset, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.pageUrl = new URL('http://localhost/');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('artists list page', () => {
|
||||||
|
test('renders a row per artist', () => {
|
||||||
|
const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 };
|
||||||
|
const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 };
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({ pages: [page([alice, bob], 2)] })
|
||||||
|
);
|
||||||
|
render(ArtistsPage);
|
||||||
|
expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a');
|
||||||
|
expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the total count from the first page', () => {
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({ pages: [page<ArtistRef>([], 1247)] })
|
||||||
|
);
|
||||||
|
render(ArtistsPage);
|
||||||
|
expect(screen.getByText(/1247 artists/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Load more button calls fetchNextPage when hasNextPage', async () => {
|
||||||
|
const fetchNextPage = vi.fn();
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({
|
||||||
|
pages: [page<ArtistRef>([], 100)],
|
||||||
|
hasNextPage: true,
|
||||||
|
fetchNextPage
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(ArtistsPage);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
|
||||||
|
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"End of library" when hasNextPage is false and at least one page loaded', () => {
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a', name: 'A', album_count: 1 }], 1)] })
|
||||||
|
);
|
||||||
|
render(ArtistsPage);
|
||||||
|
expect(screen.getByText(/end of library/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty total renders empty-library message', () => {
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||||||
|
);
|
||||||
|
render(ArtistsPage);
|
||||||
|
expect(screen.getByText(/no artists yet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the sort dropdown calls goto with ?sort=newest', async () => {
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||||||
|
);
|
||||||
|
render(ArtistsPage);
|
||||||
|
const select = screen.getByLabelText(/sort/i) as HTMLSelectElement;
|
||||||
|
await fireEvent.change(select, { target: { value: 'newest' } });
|
||||||
|
expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending state renders the list skeleton after the useDelayed window', () => {
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({ isPending: true })
|
||||||
|
);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
render(ArtistsPage);
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
flushSync(); // flush the $effect that reads delayed.value
|
||||||
|
expect(screen.getByTestId('skeleton-list')).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error state renders the error banner with retry', async () => {
|
||||||
|
const refetch = vi.fn();
|
||||||
|
(createArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockInfiniteQuery({
|
||||||
|
isError: true,
|
||||||
|
error: { code: 'server_error', message: 'db down', status: 500 },
|
||||||
|
refetch
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(ArtistsPage);
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('db down');
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||||
|
expect(refetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { createArtistQuery } from '$lib/api/queries';
|
||||||
|
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||||
|
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
||||||
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
|
import { useDelayed } from '$lib/utils/useDelayed.svelte';
|
||||||
|
import type { ApiError } from '$lib/api/client';
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? '');
|
||||||
|
const queryStore = $derived(createArtistQuery(id));
|
||||||
|
const query = $derived($queryStore);
|
||||||
|
const showSkeleton = useDelayed(() => query.isPending);
|
||||||
|
|
||||||
|
const notFound = $derived(
|
||||||
|
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<a href="/" class="text-sm text-text-secondary hover:text-text-primary">← Library</a>
|
||||||
|
|
||||||
|
{#if notFound}
|
||||||
|
<div role="alert" class="rounded border border-border bg-surface p-4">
|
||||||
|
<p>Artist not found.</p>
|
||||||
|
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
|
||||||
|
</div>
|
||||||
|
{:else if query.isError}
|
||||||
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||||
|
{:else if showSkeleton.value && !query.data}
|
||||||
|
<LibrarySkeleton variant="grid" count={12} />
|
||||||
|
{:else if query.data}
|
||||||
|
{@const detail = query.data}
|
||||||
|
<header>
|
||||||
|
<h1 class="text-2xl font-semibold">{detail.name}</h1>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if detail.albums.length === 0}
|
||||||
|
<p class="text-text-secondary">This artist has no albums in the library.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{#each detail.albums as album (album.id)}
|
||||||
|
<AlbumCard {album} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
|
import type { ArtistDetail, AlbumRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record<string, string> }));
|
||||||
|
|
||||||
|
vi.mock('$app/state', () => ({
|
||||||
|
page: {
|
||||||
|
get params() { return state.pageParams; },
|
||||||
|
get url() { return new URL('http://localhost/artists/' + state.pageParams.id); }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/api/queries', () => ({
|
||||||
|
qk: { artist: (id: string) => ['artist', id] },
|
||||||
|
createArtistQuery: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ArtistPage from './+page.svelte';
|
||||||
|
import { createArtistQuery } from '$lib/api/queries';
|
||||||
|
|
||||||
|
function album(id: string, title: string, year?: number): AlbumRef {
|
||||||
|
return {
|
||||||
|
id, title,
|
||||||
|
artist_id: 'abc', artist_name: 'Alice',
|
||||||
|
year, track_count: 10, duration_sec: 2400,
|
||||||
|
cover_url: `/api/albums/${id}/cover`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.pageParams = { id: 'abc' };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('artist detail page', () => {
|
||||||
|
test('renders artist name, subtitle, and one AlbumCard per album', () => {
|
||||||
|
const detail: ArtistDetail = {
|
||||||
|
id: 'abc', name: 'Alice', album_count: 2,
|
||||||
|
albums: [album('a1', 'First', 2020), album('a2', 'Second')]
|
||||||
|
};
|
||||||
|
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||||
|
render(ArtistPage);
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice');
|
||||||
|
expect(screen.getByText(/2 albums/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /First/ })).toHaveAttribute('href', '/albums/a1');
|
||||||
|
expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('back link points to Library', () => {
|
||||||
|
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
|
data: { id: 'abc', name: 'Alice', album_count: 0, albums: [] }
|
||||||
|
}));
|
||||||
|
render(ArtistPage);
|
||||||
|
const back = screen.getByRole('link', { name: /library/i });
|
||||||
|
expect(back).toHaveAttribute('href', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('404 renders non-retryable "Artist not found" with back link', () => {
|
||||||
|
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
|
isError: true,
|
||||||
|
error: { code: 'not_found', message: 'nope', status: 404 }
|
||||||
|
}));
|
||||||
|
render(ArtistPage);
|
||||||
|
expect(screen.getByText(/artist not found/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-404 error renders the retry banner', () => {
|
||||||
|
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
|
isError: true,
|
||||||
|
error: { code: 'server_error', message: 'boom', status: 500 }
|
||||||
|
}));
|
||||||
|
render(ArtistPage);
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('boom');
|
||||||
|
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending (after delay) renders the grid skeleton', () => {
|
||||||
|
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ isPending: true }));
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
render(ArtistPage);
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
flushSync();
|
||||||
|
expect(screen.getByTestId('skeleton-grid')).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { readable } from 'svelte/store';
|
||||||
|
import type { Page } from '$lib/api/types';
|
||||||
|
|
||||||
|
// Synthetic infinite-query object shape matching what the SPA reads from
|
||||||
|
// @tanstack/svelte-query. Enough surface area for component tests;
|
||||||
|
// real behavior is covered by the TanStack library's own tests.
|
||||||
|
//
|
||||||
|
// Returns a Svelte readable store (matching CreateInfiniteQueryResult) so
|
||||||
|
// the component can subscribe with the $store rune.
|
||||||
|
export function mockInfiniteQuery<T>(opts: {
|
||||||
|
pages?: Page<T>[];
|
||||||
|
isPending?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
fetchNextPage?: () => void;
|
||||||
|
refetch?: () => void;
|
||||||
|
} = {}) {
|
||||||
|
return readable({
|
||||||
|
data: { pages: opts.pages ?? [] },
|
||||||
|
isPending: opts.isPending ?? false,
|
||||||
|
isError: opts.isError ?? false,
|
||||||
|
error: opts.error,
|
||||||
|
hasNextPage: opts.hasNextPage ?? false,
|
||||||
|
isFetchingNextPage: opts.isFetchingNextPage ?? false,
|
||||||
|
fetchNextPage: opts.fetchNextPage ?? (() => {}),
|
||||||
|
refetch: opts.refetch ?? (() => {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockQuery<T>(opts: {
|
||||||
|
data?: T;
|
||||||
|
isPending?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
refetch?: () => void;
|
||||||
|
} = {}) {
|
||||||
|
return readable({
|
||||||
|
data: opts.data,
|
||||||
|
isPending: opts.isPending ?? false,
|
||||||
|
isError: opts.isError ?? false,
|
||||||
|
error: opts.error,
|
||||||
|
refetch: opts.refetch ?? (() => {})
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
plugins: [sveltekit(), svelteTesting()],
|
plugins: [sveltekit(), svelteTesting()],
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'src/**/*.svelte.test.ts'],
|
||||||
setupFiles: ['./vitest.setup.ts']
|
setupFiles: ['./vitest.setup.ts']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user