feat(web): /playlists index page (M7 #352 slice 1)

Replaces the placeholder route. Two sections: "Your playlists" (owned)
and "From other users" (public). Inline create form in the header
with Enter-to-submit / Esc-to-cancel. Empty-state copy when the
operator has nothing yet. Routes to /playlists/{id} on card click via
PlaylistCard.
This commit is contained in:
2026-05-03 11:22:25 -04:00
parent 71dbaaede5
commit 80a6861ded
2 changed files with 193 additions and 2 deletions
+118 -2
View File
@@ -1,2 +1,118 @@
<h1 class="text-2xl font-semibold">Playlists</h1>
<p class="mt-2 text-text-secondary">Playlists land in a subsequent plan.</p>
<script lang="ts">
import { goto } from '$app/navigation';
import { Plus } from 'lucide-svelte';
import PlaylistCard from '$lib/components/PlaylistCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
import { useQueryClient } from '@tanstack/svelte-query';
import { qk } from '$lib/api/queries';
const playlistsStore = $derived(createPlaylistsQuery());
const playlistsQuery = $derived($playlistsStore);
const queryClient = useQueryClient();
let creating = $state(false);
let newName = $state('');
let creatingBusy = $state(false);
let createError = $state<string | null>(null);
async function submitCreate() {
if (!newName.trim()) {
createError = 'Name is required';
return;
}
creatingBusy = true;
createError = null;
try {
const p = await createPlaylist({ name: newName.trim() });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
goto(`/playlists/${p.id}`);
} catch (e: unknown) {
createError = (e as { message?: string })?.message ?? 'Could not create.';
} finally {
creatingBusy = false;
creating = false;
newName = '';
}
}
</script>
<div class="mx-auto max-w-6xl px-4 py-6">
<header class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-medium text-text-primary">Playlists</h1>
<button
type="button"
onclick={() => (creating = true)}
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
>
<Plus size={14} strokeWidth={1} />
New playlist
</button>
</header>
{#if creating}
<div class="mb-6 rounded-md border border-border bg-surface p-4">
<label class="block text-sm font-medium text-text-primary">
Name
<input
type="text"
bind:value={newName}
placeholder="Saturday morning"
autofocus
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
onkeydown={(e) => { if (e.key === 'Enter') submitCreate(); if (e.key === 'Escape') creating = false; }}
/>
</label>
{#if createError}
<p class="mt-2 text-xs text-action-destructive">{createError}</p>
{/if}
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
onclick={() => { creating = false; newName = ''; createError = null; }}
>
Cancel
</button>
<button
type="button"
onclick={submitCreate}
disabled={creatingBusy || !newName.trim()}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary disabled:opacity-50"
>
Create
</button>
</div>
</div>
{/if}
{#if playlistsQuery?.isPending}
<p class="text-text-muted">Loading playlists…</p>
{:else if playlistsQuery?.isError}
<ApiErrorBanner error={playlistsQuery.error} onRetry={() => playlistsQuery.refetch()} />
{:else if playlistsQuery?.data}
<section class="mb-8">
<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>
{:else}
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each playlistsQuery.data.owned as p (p.id)}
<PlaylistCard playlist={p} />
{/each}
</div>
{/if}
</section>
{#if playlistsQuery.data.public.length > 0}
<section>
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">From other users</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each playlistsQuery.data.public as p (p.id)}
<PlaylistCard playlist={p} />
{/each}
</div>
</section>
{/if}
{/if}
</div>
@@ -0,0 +1,75 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import type { Playlist } from '$lib/api/types';
vi.mock('$lib/api/playlists', () => ({
createPlaylistsQuery: vi.fn(),
createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' })
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) })
};
});
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
import PlaylistsPage from './+page.svelte';
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
const mockedCreatePlaylistsQuery = createPlaylistsQuery as ReturnType<typeof vi.fn>;
const mockedCreatePlaylist = createPlaylist as ReturnType<typeof vi.fn>;
function p(over: Partial<Playlist>): Playlist {
return {
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A',
description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0,
created_at: '', updated_at: '', ...over
};
}
describe('Playlists index page', () => {
test('renders own + public sections', () => {
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
data: {
owned: [p({ id: 'p1', name: 'Mine' })],
public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })]
}
}));
render(PlaylistsPage);
expect(screen.getByText('Mine')).toBeInTheDocument();
expect(screen.getByText('Theirs')).toBeInTheDocument();
expect(screen.getByText(/your playlists/i)).toBeInTheDocument();
expect(screen.getByText(/from other users/i)).toBeInTheDocument();
});
test('empty-state copy when operator has no playlists', () => {
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
data: { owned: [], public: [] }
}));
render(PlaylistsPage);
expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument();
});
test('hides "From other users" section when empty', () => {
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
data: { owned: [p({ id: 'p1', name: 'Mine' })], public: [] }
}));
render(PlaylistsPage);
expect(screen.queryByText(/from other users/i)).not.toBeInTheDocument();
});
test('Create button reveals inline form; submit creates and navigates', async () => {
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ data: { owned: [], public: [] } }));
render(PlaylistsPage);
await fireEvent.click(screen.getByRole('button', { name: /new playlist/i }));
const input = screen.getByPlaceholderText(/saturday morning/i);
await fireEvent.input(input, { target: { value: 'Test' } });
await fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' }));
});
});