diff --git a/web/src/lib/components/EmptyState.svelte b/web/src/lib/components/EmptyState.svelte new file mode 100644 index 00000000..4a1eba04 --- /dev/null +++ b/web/src/lib/components/EmptyState.svelte @@ -0,0 +1,42 @@ + + +{#if variant === 'block'} +
+

{title}

+ {#if hint} +

{hint}

+ {/if} + {#if actions} +
+ {@render actions()} +
+ {/if} +
+{:else} +

+ {title} + {#if hint} {hint}{/if} + {#if actions} + {@render actions()} + {/if} +

+{/if} diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte index 69d24981..53bdd960 100644 --- a/web/src/routes/library/albums/+page.svelte +++ b/web/src/routes/library/albums/+page.svelte @@ -6,6 +6,7 @@ import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import InfiniteScrollSentinel from '$lib/components/InfiniteScrollSentinel.svelte'; import QuickFilter from '$lib/components/QuickFilter.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; import { useDelayed } from '$lib/utils/useDelayed.svelte'; import type { AlbumRef } from '$lib/api/types'; @@ -47,7 +48,19 @@ {:else if showSkeleton.value && albums.length === 0}

Loading…

{:else if !query.isPending && total === 0} -

No albums yet — scan a library folder via the server's config.

+ + {#snippet actions()} + + Open settings + + {/snippet} + {:else if filter.trim() && filtered.length === 0}

No albums in your loaded library match diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte index 1a3352f0..fe36e085 100644 --- a/web/src/routes/library/artists/+page.svelte +++ b/web/src/routes/library/artists/+page.svelte @@ -6,6 +6,7 @@ import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import InfiniteScrollSentinel from '$lib/components/InfiniteScrollSentinel.svelte'; import QuickFilter from '$lib/components/QuickFilter.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; import { useDelayed } from '$lib/utils/useDelayed.svelte'; import type { ArtistRef } from '$lib/api/types'; @@ -45,7 +46,19 @@ {:else if showSkeleton.value && artists.length === 0}

Loading…

{:else if !query.isPending && total === 0} -

No artists yet — scan a library folder via the server's config.

+ + {#snippet actions()} + + Open settings + + {/snippet} + {:else if filter.trim() && filtered.length === 0}

No artists in your loaded library match diff --git a/web/src/routes/library/history/+page.svelte b/web/src/routes/library/history/+page.svelte index 939946f9..c41a9966 100644 --- a/web/src/routes/library/history/+page.svelte +++ b/web/src/routes/library/history/+page.svelte @@ -7,6 +7,7 @@ import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import QuickFilter from '$lib/components/QuickFilter.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; const queryStore = createHistoryQuery(); const query = $derived($queryStore); @@ -40,7 +41,16 @@ {:else if query.isError} {:else if flatEvents.length === 0} -

No listening history yet.

+ + {#snippet actions()} + + Listen to something + + {/snippet} + {:else if filter.trim() && filteredEvents.length === 0}

No history in your loaded events matches diff --git a/web/src/routes/library/liked/+page.svelte b/web/src/routes/library/liked/+page.svelte index 7c9185ba..6897d58c 100644 --- a/web/src/routes/library/liked/+page.svelte +++ b/web/src/routes/library/liked/+page.svelte @@ -9,6 +9,7 @@ import AlbumCard from '$lib/components/AlbumCard.svelte'; import TrackRow from '$lib/components/TrackRow.svelte'; import QuickFilter from '$lib/components/QuickFilter.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; const artistsStore = $derived(createLikedArtistsInfiniteQuery()); const albumsStore = $derived(createLikedAlbumsInfiniteQuery()); @@ -41,6 +42,9 @@ const filterMatchesNothing = $derived( !!q && fArtists.length === 0 && fAlbums.length === 0 && fTracks.length === 0 ); + const allEmpty = $derived( + !q && artistsTotal === 0 && albumsTotal === 0 && tracksTotal === 0 + ); {pageTitle('Library · Liked')} @@ -60,11 +64,34 @@

{/if} - {#if !q || fArtists.length > 0} + {#if allEmpty} + + {#snippet actions()} + + Explore Home + + + Browse albums + + {/snippet} + + {/if} + + {#if !allEmpty && (!q || fArtists.length > 0)}

Artists

{#if artistsTotal === 0} -

No liked artists yet.

+ + {#snippet actions()} + Browse artists → + {/snippet} + {:else}
{#each fArtists as a (a.id)} @@ -85,11 +112,18 @@
{/if} - {#if !q || fAlbums.length > 0} + {#if !allEmpty && (!q || fAlbums.length > 0)}

Albums

{#if albumsTotal === 0} -

No liked albums yet.

+ + {#snippet actions()} + Browse albums → + {/snippet} + {:else}
{#each fAlbums as al (al.id)} @@ -110,11 +144,18 @@
{/if} - {#if !q || fTracks.length > 0} + {#if !allEmpty && (!q || fTracks.length > 0)}

Tracks

{#if tracksTotal === 0} -

No liked tracks yet.

+ + {#snippet actions()} + Find something new → + {/snippet} + {:else}
{#each fTracks as t, i (t.id)} diff --git a/web/src/routes/library/liked/liked.test.ts b/web/src/routes/library/liked/liked.test.ts index 7fb0fd53..3bd48657 100644 --- a/web/src/routes/library/liked/liked.test.ts +++ b/web/src/routes/library/liked/liked.test.ts @@ -64,7 +64,7 @@ describe('liked library page', () => { expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); }); - test('empty section renders "No liked X yet" hint', () => { + test('all three sections empty renders the whole-tab onboarding card', () => { (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 0)] }) ); @@ -75,7 +75,28 @@ describe('liked library page', () => { mockInfiniteQuery({ pages: [page([], 0)] }) ); render(LikedPage); - expect(screen.getAllByText(/no liked/i)).toHaveLength(3); + // The whole-tab EmptyState replaces the three per-section hints + // when every section is empty — single heading + action affordances. + expect(screen.getByRole('heading', { name: /no likes yet/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /explore home/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /browse albums/i })).toBeInTheDocument(); + }); + + test('one populated section + two empty subsections renders inline hints', () => { + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([{ id: 'a1', name: 'Miles' } as ArtistRef], 1)] }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(LikedPage); + expect(screen.getByText(/no liked albums yet/i)).toBeInTheDocument(); + expect(screen.getByText(/no liked tracks yet/i)).toBeInTheDocument(); + // Inline variant uses link affordances, not the big card heading. + expect(screen.queryByRole('heading', { name: /no likes yet/i })).not.toBeInTheDocument(); }); test('Load more calls fetchNextPage on each section that has more', async () => { diff --git a/web/src/routes/library/playlists/+page.svelte b/web/src/routes/library/playlists/+page.svelte index e1d5cd70..a7af12c9 100644 --- a/web/src/routes/library/playlists/+page.svelte +++ b/web/src/routes/library/playlists/+page.svelte @@ -5,6 +5,7 @@ import PlaylistCard from '$lib/components/PlaylistCard.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import QuickFilter from '$lib/components/QuickFilter.svelte'; + import EmptyState from '$lib/components/EmptyState.svelte'; import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists'; import { useQueryClient } from '@tanstack/svelte-query'; import { qk } from '$lib/api/queries'; @@ -134,7 +135,21 @@

Your playlists

{#if playlistsQuery.data.owned.length === 0} -

No playlists yet. Click "New playlist" to start one.

+ + {#snippet actions()} + + {/snippet} + {:else}
{#each ownedFiltered as p (p.id)}