feat(web): wire LikeButton into TrackRow, AlbumCard, ArtistRow, PlayerBar
Heart appears on every entity rendering surface. ArtistRow restructured to a <div> with the link as a positioned overlay so the heart button can nest without invalid <button>-in-<a> markup. Shell nav grows a 'Liked' entry pointing at /library/liked.
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '$lib/player/store.svelte';
|
||||||
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
let { album }: { album: AlbumRef } = $props();
|
let { album }: { album: AlbumRef } = $props();
|
||||||
|
|
||||||
@@ -35,10 +36,13 @@
|
|||||||
<div class="text-xs text-text-secondary">{album.year}</div>
|
<div class="text-xs text-text-secondary">{album.year}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<div class="absolute right-2 top-2 flex gap-1">
|
||||||
type="button"
|
<LikeButton entityType="album" entityId={album.id} />
|
||||||
aria-label="Add to queue"
|
<button
|
||||||
onclick={onAddClick}
|
type="button"
|
||||||
class="absolute right-2 top-2 rounded-full bg-surface/80 px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
aria-label="Add to queue"
|
||||||
>+</button>
|
onclick={onAddClick}
|
||||||
|
class="rounded-full bg-surface/80 px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
|
|
||||||
vi.mock('$lib/api/client', () => ({
|
vi.mock('$lib/api/client', () => ({
|
||||||
api: { get: vi.fn() }
|
api: { get: vi.fn() }
|
||||||
@@ -10,6 +11,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
enqueueTracks: vi.fn()
|
enqueueTracks: 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 AlbumCard from './AlbumCard.svelte';
|
import AlbumCard from './AlbumCard.svelte';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '$lib/player/store.svelte';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ArtistRef } from '$lib/api/types';
|
import type { ArtistRef } from '$lib/api/types';
|
||||||
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
let { artist }: { artist: ArtistRef } = $props();
|
let { artist }: { artist: ArtistRef } = $props();
|
||||||
|
|
||||||
@@ -7,17 +8,22 @@
|
|||||||
const countLabel = $derived(artist.album_count === 1 ? '1 album' : `${artist.album_count} albums`);
|
const countLabel = $derived(artist.album_count === 1 ? '1 album' : `${artist.album_count} albums`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<div class="relative flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover">
|
||||||
href={`/artists/${artist.id}`}
|
<a
|
||||||
class="flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
href={`/artists/${artist.id}`}
|
||||||
>
|
class="absolute inset-0 focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
aria-label={artist.name}
|
||||||
|
></a>
|
||||||
<span
|
<span
|
||||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold"
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold relative"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{initial}
|
{initial}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-1 truncate">{artist.name}</span>
|
<span class="flex-1 truncate relative">{artist.name}</span>
|
||||||
<span class="text-sm text-text-secondary">{countLabel}</span>
|
<span class="text-sm text-text-secondary relative">{countLabel}</span>
|
||||||
<span class="text-text-secondary" aria-hidden="true">›</span>
|
<span class="relative">
|
||||||
</a>
|
<LikeButton entityType="artist" entityId={artist.id} />
|
||||||
|
</span>
|
||||||
|
<span class="text-text-secondary relative" aria-hidden="true">›</span>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
import ArtistRow from './ArtistRow.svelte';
|
import ArtistRow from './ArtistRow.svelte';
|
||||||
import type { ArtistRef } from '$lib/api/types';
|
import type { ArtistRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
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: () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 };
|
const artist: ArtistRef = { id: 'abc', name: 'alice', album_count: 12 };
|
||||||
|
|
||||||
describe('ArtistRow', () => {
|
describe('ArtistRow', () => {
|
||||||
@@ -11,14 +27,14 @@ describe('ArtistRow', () => {
|
|||||||
|
|
||||||
const link = screen.getByRole('link');
|
const link = screen.getByRole('link');
|
||||||
expect(link).toHaveAttribute('href', '/artists/abc');
|
expect(link).toHaveAttribute('href', '/artists/abc');
|
||||||
expect(link).toHaveTextContent('alice');
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||||
expect(link).toHaveTextContent('12 albums');
|
expect(screen.getByText('12 albums')).toBeInTheDocument();
|
||||||
expect(link).toHaveTextContent('A'); // initial letter
|
expect(screen.getByText('A')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('singular "1 album" when album_count is 1', () => {
|
test('singular "1 album" when album_count is 1', () => {
|
||||||
render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } });
|
render(ArtistRow, { props: { artist: { ...artist, album_count: 1 } } });
|
||||||
expect(screen.getByRole('link')).toHaveTextContent('1 album');
|
expect(screen.getByText('1 album')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('empty name still renders a safe initial', () => {
|
test('empty name still renders a safe initial', () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
} from '$lib/player/store.svelte';
|
} from '$lib/player/store.svelte';
|
||||||
import { formatDuration } from '$lib/media/duration';
|
import { formatDuration } from '$lib/media/duration';
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
const current = $derived(player.current);
|
const current = $derived(player.current);
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
onerror={onCoverError}
|
onerror={onCoverError}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium">{current.title}</div>
|
<div class="truncate text-sm font-medium">{current.title}</div>
|
||||||
<a
|
<a
|
||||||
href={`/artists/${current.artist_id}`}
|
href={`/artists/${current.artist_id}`}
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
{current.artist_name}
|
{current.artist_name}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<LikeButton entityType="track" entityId={current.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: seek row + transport row -->
|
<!-- Center: seek row + transport row -->
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
|
|
||||||
// Mutable state the mocked store reads from.
|
// Mutable state the mocked store reads from.
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
@@ -40,6 +41,21 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
playQueue: vi.fn()
|
playQueue: 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 PlayerBar from './PlayerBar.svelte';
|
import PlayerBar from './PlayerBar.svelte';
|
||||||
import {
|
import {
|
||||||
togglePlay, skipNext, skipPrev, seekTo, setVolume,
|
togglePlay, skipNext, skipPrev, seekTo, setVolume,
|
||||||
|
|||||||
@@ -22,9 +22,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', label: 'Library' },
|
{ href: '/', label: 'Library' },
|
||||||
{ href: '/search', label: 'Search' },
|
{ href: '/library/liked', label: 'Liked' },
|
||||||
{ href: '/playlists', label: 'Playlists' }
|
{ href: '/search', label: 'Search' },
|
||||||
|
{ href: '/playlists', label: 'Playlists' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { formatDuration } from '$lib/media/duration';
|
import { formatDuration } from '$lib/media/duration';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||||
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
type PlayHandler = (tracks: TrackRef[], index: number) => void;
|
type PlayHandler = (tracks: TrackRef[], index: number) => void;
|
||||||
|
|
||||||
@@ -41,12 +42,13 @@
|
|||||||
aria-label={track.title}
|
aria-label={track.title}
|
||||||
onclick={onRowClick}
|
onclick={onRowClick}
|
||||||
onkeydown={onRowKey}
|
onkeydown={onRowKey}
|
||||||
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<span class="text-right tabular-nums text-text-secondary">
|
<span class="text-right tabular-nums text-text-secondary">
|
||||||
{track.track_number ?? '—'}
|
{track.track_number ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">{track.title}</span>
|
<span class="truncate">{track.title}</span>
|
||||||
|
<LikeButton entityType="track" entityId={track.id} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Add to queue"
|
aria-label="Add to queue"
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
import { readable } from 'svelte/store';
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('$lib/player/store.svelte', () => ({
|
||||||
playQueue: vi.fn(),
|
playQueue: vi.fn(),
|
||||||
enqueueTrack: vi.fn()
|
enqueueTrack: 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 TrackRow from './TrackRow.svelte';
|
import TrackRow from './TrackRow.svelte';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user