feat(web/m7-380): animate pending placeholder + add play overlay to PlaylistCard

This commit is contained in:
2026-05-04 19:42:30 -04:00
parent 34615fffbd
commit b290b4ff0b
4 changed files with 156 additions and 36 deletions
+71 -27
View File
@@ -1,38 +1,82 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Play } from 'lucide-svelte';
import type { Playlist } from '$lib/api/types';
import { user } from '$lib/auth/store.svelte';
import { getPlaylist } from '$lib/api/playlists';
import { playQueue } from '$lib/player/store.svelte';
let { playlist }: { playlist: Playlist } = $props();
const isOwn = $derived(user.value?.id === playlist.user_id);
let starting = $state(false);
async function onPlayClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (starting) return;
starting = true;
try {
const detail = await getPlaylist(playlist.id);
if (detail.tracks.length > 0) {
playQueue(detail.tracks, 0);
}
} finally {
starting = false;
}
}
</script>
<button
type="button"
onclick={() => goto(`/playlists/${playlist.id}`)}
class="group flex w-full flex-col items-start gap-2 rounded-md p-2 text-left hover:bg-surface-hover"
>
<div class="aspect-square w-full overflow-hidden rounded-md bg-surface-hover">
{#if playlist.cover_url}
<img
src={playlist.cover_url}
alt=""
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full w-full items-center justify-center text-text-muted">
<span class="text-xs">No tracks yet</span>
</div>
{/if}
</div>
<div class="w-full min-w-0">
<div class="truncate text-sm font-medium text-text-primary">{playlist.name}</div>
<div class="truncate text-xs text-text-muted">
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
{#if !isOwn}
· by {playlist.owner_username}
<div class="card relative">
<a
href={`/playlists/${playlist.id}`}
class="group flex w-full flex-col items-start gap-2 rounded-md p-2 text-left hover:bg-surface-hover"
>
<div class="art-wrap relative aspect-square w-full overflow-hidden rounded-md bg-surface-hover">
{#if playlist.cover_url}
<img
src={playlist.cover_url}
alt=""
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full w-full items-center justify-center text-text-muted">
<span class="text-xs">No tracks yet</span>
</div>
{/if}
<button
type="button"
aria-label={`Play ${playlist.name}`}
onclick={onPlayClick}
disabled={starting || playlist.track_count === 0}
class="play-overlay absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full disabled:opacity-50"
>
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
</button>
</div>
</div>
</button>
<div class="w-full min-w-0">
<div class="truncate text-sm font-medium text-text-primary">{playlist.name}</div>
<div class="truncate text-xs text-text-muted">
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
{#if !isOwn}
· by {playlist.owner_username}
{/if}
</div>
</div>
</a>
</div>
<style>
.play-overlay {
background: var(--fs-accent);
color: var(--fs-parchment);
opacity: 0;
transition: opacity 150ms ease;
}
@media (hover: hover) {
.card:hover .play-overlay { opacity: 1; }
}
@media (hover: none) {
.play-overlay { opacity: 1; }
}
</style>
+77 -4
View File
@@ -1,13 +1,48 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import PlaylistCard from './PlaylistCard.svelte';
import type { Playlist } from '$lib/api/types';
import type { Playlist, PlaylistDetail } from '$lib/api/types';
vi.mock('$lib/auth/store.svelte', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/playlists', () => ({
getPlaylist: vi.fn().mockResolvedValue({
id: 'p-1',
user_id: 'u-self',
owner_username: 'me',
name: 'Saturday morning',
description: '',
is_public: false,
kind: 'user',
system_variant: null,
seed_artist_id: null,
cover_url: '',
track_count: 1,
duration_sec: 60,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
tracks: [
{
position: 0,
track_id: 't-1',
album_id: 'al-1',
artist_id: 'ar-1',
title: 'Test Track',
artist_name: 'Test Artist',
album_title: 'Test Album',
duration_sec: 60,
stream_url: '/s/t-1',
added_at: '2026-01-01T00:00:00Z'
}
]
} as PlaylistDetail)
}));
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn()
}));
const base: Playlist = {
id: 'p-1',
@@ -27,6 +62,10 @@ const base: Playlist = {
};
describe('PlaylistCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders own playlist without owner attribution', () => {
render(PlaylistCard, { props: { playlist: base } });
expect(screen.getByText('Saturday morning')).toBeInTheDocument();
@@ -34,6 +73,12 @@ describe('PlaylistCard', () => {
expect(screen.queryByText(/by /)).not.toBeInTheDocument();
});
test('navigates to playlist when clicking the card', () => {
render(PlaylistCard, { props: { playlist: base } });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/playlists/p-1');
});
test('renders cover image when cover_url is set', () => {
// The <img> has alt="" (decorative — the playlist name is in the
// sibling text below, so duplicating it for screen readers would be
@@ -60,4 +105,32 @@ describe('PlaylistCard', () => {
render(PlaylistCard, { props: { playlist: { ...base, track_count: 1 } } });
expect(screen.getByText(/1\s+track\b/i)).toBeInTheDocument();
});
test('play button exists with correct aria label', () => {
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
expect(playButton).toBeInTheDocument();
});
test('play button is disabled when track_count is 0', () => {
render(PlaylistCard, { props: { playlist: { ...base, track_count: 0 } } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
expect(playButton).toBeDisabled();
});
test('play button calls playQueue with tracks when clicked', async () => {
const { getPlaylist } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
// Allow promises to resolve
await new Promise(resolve => setTimeout(resolve, 10));
expect(getPlaylist).toHaveBeenCalledWith('p-1');
expect(playQueue).toHaveBeenCalled();
});
});
@@ -20,12 +20,13 @@
}
});
// animate-pulse on the cover area only when in_flight (variant='building').
// Other variants are static so failure/empty states don't read as "active".
// animate-pulse on the cover area when variant is 'building' or 'pending'.
// 'pending' shows the animation since it's awaiting results within 24h.
// 'failed' and 'seed-needed' are static terminal states.
const coverClasses = $derived(
'aspect-square w-full overflow-hidden rounded-md bg-surface-hover ' +
'flex items-center justify-center text-text-muted ' +
(variant === 'building' ? 'animate-pulse' : '')
(variant === 'building' || variant === 'pending' ? 'animate-pulse' : '')
);
</script>
@@ -27,8 +27,10 @@ describe('PlaylistPlaceholderCard', () => {
expect(screen.getByText(/Listen to more variety/i)).toBeInTheDocument();
});
test('pending variant (default) shows the within-24h copy', () => {
render(PlaylistPlaceholderCard, { props: { label: 'For You' } });
test('pending variant (default) shows within-24h copy and pulses', () => {
const { container } = render(PlaylistPlaceholderCard, { props: { label: 'For You' } });
expect(screen.getByText(/within 24h/i)).toBeInTheDocument();
const cover = container.querySelector('[data-variant="pending"] .aspect-square');
expect(cover?.className).toMatch(/animate-pulse/);
});
});