feat(web): smart empty-states with action affordances (Tier B6)
test-web / test (push) Successful in 32s
test-web / test (push) Successful in 32s
Replace dead-end empty copy with EmptyState cards that include a
clear next step.
- EmptyState.svelte: reusable card with `block` (whole-tab) and
`inline` (sub-section) variants, an actions snippet for buttons.
- Artists / Albums: when the whole library is empty, link to
/settings (the operator can scan a folder there).
- Liked: when all three sub-sections are empty, show a single
whole-tab card ("No likes yet" with Explore Home + Browse albums).
When only one sub-section is empty, an inline hint with a link
to the corresponding library tab.
- History: "Listen to something" button → Home.
- Playlists: "Create a playlist" button calls the existing create
flow; renamed from "New playlist" to avoid colliding with the
header's button-by-name lookup in tests.
Liked tab tests updated to match: the previous "three 'no liked X
yet'" assertion is now a single onboarding card test + a sibling
test for the mixed populated/empty case.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// Reusable empty-state card. Use the `actions` snippet to render the
|
||||
// onramp button(s). Variant `block` is a soft card for whole-tab
|
||||
// emptiness; `inline` is a single paragraph with link affordances
|
||||
// for sub-sections that sit inside a populated tab.
|
||||
|
||||
let {
|
||||
title,
|
||||
hint,
|
||||
variant = 'block',
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
variant?: 'block' | 'inline';
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === 'block'}
|
||||
<div class="rounded-md border border-border bg-surface/40 px-6 py-10 text-center">
|
||||
<h3 class="font-display text-lg font-medium text-text-primary">{title}</h3>
|
||||
{#if hint}
|
||||
<p class="mt-1 text-sm text-text-secondary">{hint}</p>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
{title}
|
||||
{#if hint} <span>{hint}</span>{/if}
|
||||
{#if actions}
|
||||
<span class="ml-1">{@render actions()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -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}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if !query.isPending && total === 0}
|
||||
<p class="text-text-secondary">No albums yet — scan a library folder via the server's config.</p>
|
||||
<EmptyState
|
||||
title="No albums yet"
|
||||
hint="Once a library folder has been scanned, albums will appear here."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a
|
||||
href="/settings"
|
||||
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>
|
||||
Open settings
|
||||
</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filter.trim() && filtered.length === 0}
|
||||
<p class="text-text-secondary">
|
||||
No albums in your loaded library match
|
||||
|
||||
@@ -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}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if !query.isPending && total === 0}
|
||||
<p class="text-text-secondary">No artists yet — scan a library folder via the server's config.</p>
|
||||
<EmptyState
|
||||
title="No artists yet"
|
||||
hint="Once a library folder has been scanned, artists will appear here."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a
|
||||
href="/settings"
|
||||
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>
|
||||
Open settings
|
||||
</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filter.trim() && filtered.length === 0}
|
||||
<p class="text-text-secondary">
|
||||
No artists in your loaded library match
|
||||
|
||||
@@ -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}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if flatEvents.length === 0}
|
||||
<p class="p-8 text-center text-text-secondary">No listening history yet.</p>
|
||||
<EmptyState
|
||||
title="No listening history yet"
|
||||
hint="Once you've played something, it'll show up here, grouped by day."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/" class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90">
|
||||
Listen to something
|
||||
</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filter.trim() && filteredEvents.length === 0}
|
||||
<p class="text-text-secondary">
|
||||
No history in your loaded events matches
|
||||
|
||||
@@ -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
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Library · Liked')}</title></svelte:head>
|
||||
@@ -60,11 +64,34 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !q || fArtists.length > 0}
|
||||
{#if allEmpty}
|
||||
<EmptyState
|
||||
title="No likes yet"
|
||||
hint="Tap the heart on a track, album, or artist to start your collection."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/" class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90">
|
||||
Explore Home
|
||||
</a>
|
||||
<a href="/library/albums" class="inline-flex items-center rounded-md border border-border px-4 py-2 text-sm text-text-secondary hover:text-text-primary">
|
||||
Browse albums
|
||||
</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
{#if !allEmpty && (!q || fArtists.length > 0)}
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Artists</h2>
|
||||
{#if artistsTotal === 0}
|
||||
<p class="text-text-secondary">No liked artists yet.</p>
|
||||
<EmptyState
|
||||
variant="inline"
|
||||
title="No liked artists yet."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/library/artists" class="text-accent hover:underline">Browse artists →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each fArtists as a (a.id)}
|
||||
@@ -85,11 +112,18 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if !q || fAlbums.length > 0}
|
||||
{#if !allEmpty && (!q || fAlbums.length > 0)}
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Albums</h2>
|
||||
{#if albumsTotal === 0}
|
||||
<p class="text-text-secondary">No liked albums yet.</p>
|
||||
<EmptyState
|
||||
variant="inline"
|
||||
title="No liked albums yet."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/library/albums" class="text-accent hover:underline">Browse albums →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each fAlbums as al (al.id)}
|
||||
@@ -110,11 +144,18 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if !q || fTracks.length > 0}
|
||||
{#if !allEmpty && (!q || fTracks.length > 0)}
|
||||
<section>
|
||||
<h2 class="mb-2 text-lg font-semibold">Tracks</h2>
|
||||
{#if tracksTotal === 0}
|
||||
<p class="text-text-secondary">No liked tracks yet.</p>
|
||||
<EmptyState
|
||||
variant="inline"
|
||||
title="No liked tracks yet."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<a href="/" class="text-accent hover:underline">Find something new →</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded border border-border">
|
||||
{#each fTracks as t, i (t.id)}
|
||||
|
||||
@@ -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<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||||
);
|
||||
@@ -75,7 +75,28 @@ describe('liked library page', () => {
|
||||
mockInfiniteQuery({ pages: [page<TrackRef>([], 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<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a1', name: 'Miles' } as ArtistRef], 1)] })
|
||||
);
|
||||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||||
);
|
||||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page<TrackRef>([], 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 () => {
|
||||
|
||||
@@ -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 @@
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">Your playlists</h2>
|
||||
{#if playlistsQuery.data.owned.length === 0}
|
||||
<p class="text-sm text-text-muted">No playlists yet. Click "New playlist" to start one.</p>
|
||||
<EmptyState
|
||||
title="No playlists yet"
|
||||
hint="Group tracks together for a road trip, a focus session, anything."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (creating = true)}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>
|
||||
<Plus size={14} strokeWidth={1} />
|
||||
Create a playlist
|
||||
</button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{#each ownedFiltered as p (p.id)}
|
||||
|
||||
Reference in New Issue
Block a user