feat(web): rewrite / as home page composing 4 horizontal-scroll sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+96
-67
@@ -1,83 +1,112 @@
|
||||
<script lang="ts">
|
||||
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 { createHomeQuery } from '$lib/api/home';
|
||||
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
|
||||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||
import ArtistCard from '$lib/components/ArtistCard.svelte';
|
||||
import CompactTrackCard from '$lib/components/CompactTrackCard.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 queryStore = createHomeQuery();
|
||||
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 data = $derived(query.data);
|
||||
|
||||
const showSkeleton = useDelayed(() => query.isPending);
|
||||
|
||||
function onSortChange(e: Event) {
|
||||
const value = (e.currentTarget as HTMLSelectElement).value as ArtistSort;
|
||||
goto(`?sort=${value}`, { replaceState: true });
|
||||
// Each "Most played" row gets 25 of the 75 tracks (server returns up
|
||||
// to 75; rows 0/1/2 slice [0..25), [25..50), [50..75)).
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
</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>
|
||||
|
||||
<div class="space-y-8">
|
||||
{#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>
|
||||
{:else if showSkeleton.value && !data}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if data}
|
||||
<!-- Recently added: 50 albums in 2 rows of 25 -->
|
||||
<section class="space-y-3">
|
||||
<header>
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Recently added</h2>
|
||||
</header>
|
||||
{#if data.recently_added_albums.length === 0}
|
||||
<p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p>
|
||||
{:else}
|
||||
{#each chunk(data.recently_added_albums, 25) as row, i (i)}
|
||||
<HorizontalScrollRow items={row} ariaLabel="Recently added row {i + 1}">
|
||||
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
||||
<div class="w-44"><AlbumCard {album} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#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}
|
||||
<!-- Rediscover: albums + artists -->
|
||||
<section class="space-y-3">
|
||||
<header>
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Rediscover</h2>
|
||||
</header>
|
||||
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0}
|
||||
<p class="text-text-secondary">No forgotten favourites yet. Like some albums or artists to fill this in.</p>
|
||||
{:else}
|
||||
{#if data.rediscover_albums.length > 0}
|
||||
<HorizontalScrollRow items={data.rediscover_albums} ariaLabel="Rediscover albums">
|
||||
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
||||
<div class="w-44"><AlbumCard {album} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
{#if data.rediscover_artists.length > 0}
|
||||
<HorizontalScrollRow items={data.rediscover_artists} ariaLabel="Rediscover artists">
|
||||
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
||||
<div class="w-36"><ArtistCard {artist} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Most played: 75 tracks in 3 rows of 25 -->
|
||||
<section class="space-y-3">
|
||||
<header>
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Most played</h2>
|
||||
</header>
|
||||
{#if data.most_played_tracks.length === 0}
|
||||
<p class="text-text-secondary">No plays to draw from. Listen to something.</p>
|
||||
{:else}
|
||||
{#each chunk(data.most_played_tracks, 25) as row, i (i)}
|
||||
<HorizontalScrollRow items={row} ariaLabel="Most played row {i + 1}">
|
||||
{#snippet item(track: import('$lib/api/types').TrackRef, idx: number)}
|
||||
<CompactTrackCard
|
||||
{track}
|
||||
sectionTracks={row}
|
||||
index={idx}
|
||||
/>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Last played artists -->
|
||||
<section class="space-y-3">
|
||||
<header>
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Last played</h2>
|
||||
</header>
|
||||
{#if data.last_played_artists.length === 0}
|
||||
<p class="text-text-secondary">No recent plays.</p>
|
||||
{:else}
|
||||
<HorizontalScrollRow items={data.last_played_artists} ariaLabel="Last played artists">
|
||||
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
||||
<div class="w-36"><ArtistCard {artist} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { flushSync } from 'svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
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()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
createLikedIdsQuery: () => readable({
|
||||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
likeEntity: vi.fn(),
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({}) };
|
||||
});
|
||||
|
||||
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', sort_name: 'Alice', album_count: 3, cover_url: '' };
|
||||
const bob: ArtistRef = { id: 'b', name: 'Bob', sort_name: 'Bob', album_count: 1, cover_url: '' };
|
||||
(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', sort_name: 'A', album_count: 1, cover_url: '' }], 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user