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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user