feat(web/m7-380): animate pending placeholder + add play overlay to PlaylistCard
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user