Files
minstrel/web/src/routes/playlists/[id]/+page.svelte
T
bvandeusen cc81e0f183
test-web / test (push) Successful in 31s
feat(web): copy-link button on owned public playlists (Tier C12)
Add a Link icon button to the playlist header that copies the
absolute /playlists/[id] URL to the clipboard. Visible only when
isOwner && pl.is_public. Falls back to a window.prompt() in
insecure contexts where navigator.clipboard is unavailable.

Tests: button visibility (private vs public owner) + clipboard
write target.
2026-06-01 14:13:05 -04:00

297 lines
10 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { pageTitle } from '$lib/branding';
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import PlaylistTrackRow from '$lib/components/PlaylistTrackRow.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import {
createPlaylistQuery,
updatePlaylist,
deletePlaylist,
removePlaylistTrack,
reorderPlaylist,
refreshSystem
} from '$lib/api/playlists';
import { qk } from '$lib/api/queries';
import { user } from '$lib/auth/store.svelte';
import { errCode, errMessage } from '$lib/api/errors';
import { playQueue } from '$lib/player/store.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
import type { TrackRef } from '$lib/api/types';
// SvelteKit types page.params.id as string | undefined (the route
// could in principle render with the slot empty); empty-string fallback
// keeps every downstream call typed and falls back to a 404 if the
// operator ever lands here without a real id.
const id = $derived(page.params.id ?? '');
const queryClient = useQueryClient();
const playlistStore = $derived(createPlaylistQuery(id));
const playlistQuery = $derived($playlistStore);
const isOwner = $derived(user.value?.id === playlistQuery?.data?.user_id);
async function onMove(fromPos: number, toPos: number) {
if (!playlistQuery?.data) return;
const tracks = playlistQuery.data.tracks;
// Clamp toPos so a drag past the list edges resolves cleanly to the
// first/last slot rather than producing an out-of-range index.
const clamped = Math.max(0, Math.min(tracks.length - 1, toPos));
if (fromPos === clamped) return;
const cur = tracks.map((t) => t.position);
const moved = cur.splice(fromPos, 1)[0];
cur.splice(clamped, 0, moved);
try {
await reorderPlaylist(id, cur);
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
} catch (e: unknown) {
alert(errMessage(e));
}
}
async function onRemove(position: number) {
try {
await removePlaylistTrack(id, position);
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
} catch (e: unknown) {
alert(errMessage(e));
}
}
function onPlay(position: number) {
if (!playlistQuery?.data) return;
// Skip rows where track_id is null (track was removed from library).
const live = playlistQuery.data.tracks
.filter((t) => t.track_id !== null)
.map(toTrackRef);
// Find which `live` index corresponds to the clicked position. Some
// positions in the original list may be unavailable (filtered out),
// so the index in the playable list != the playlist position.
const clickedTrackID = playlistQuery.data.tracks.find((t) => t.position === position)?.track_id;
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
playQueue(live, Math.max(0, startIdx));
}
function toTrackRef(t: {
track_id: string | null;
album_id: string | null;
artist_id: string | null;
title: string;
album_title: string;
artist_name: string;
duration_sec: number;
stream_url: string | null;
}): TrackRef {
return {
id: t.track_id!,
title: t.title,
album_id: t.album_id ?? '',
album_title: t.album_title,
artist_id: t.artist_id ?? '',
artist_name: t.artist_name,
duration_sec: t.duration_sec,
stream_url: t.stream_url ?? ''
} as unknown as TrackRef;
}
let editing = $state(false);
let editName = $state('');
let editDesc = $state('');
let editIsPublic = $state(false);
function startEdit() {
if (!playlistQuery?.data) return;
editName = playlistQuery.data.name;
editDesc = playlistQuery.data.description;
editIsPublic = playlistQuery.data.is_public;
editing = true;
}
async function submitEdit() {
try {
await updatePlaylist(id, { name: editName, description: editDesc, is_public: editIsPublic });
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
editing = false;
} catch (e: unknown) {
alert(errMessage(e));
}
}
async function submitDelete() {
if (!confirm('Delete this playlist? This cannot be undone.')) return;
try {
await deletePlaylist(id);
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
goto('/playlists');
} catch (e: unknown) {
alert(errMessage(e));
}
}
async function onCopyLink() {
const href = `${window.location.origin}/playlists/${id}`;
try {
await navigator.clipboard.writeText(href);
pushToast('Link copied to clipboard.');
} catch {
// navigator.clipboard fails in insecure contexts and older browsers.
// Fall back to selecting the link in a prompt so the user can copy.
window.prompt('Copy the playlist link:', href);
}
}
// --- System-playlist refresh (#411 R2: generic by-kind) ---
let refreshingSystem = $state(false);
async function onRefreshSystem(variant: string) {
refreshingSystem = true;
try {
await refreshSystem(variant);
pushToast('Refreshed.');
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
} catch (e: unknown) {
pushToast(`Refresh failed: ${errCode(e)}`, 'error');
} finally {
refreshingSystem = false;
}
}
</script>
<svelte:head>
<title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-6">
{#if playlistQuery?.isPending}
<p class="text-text-muted">Loading…</p>
{:else if playlistQuery?.isError}
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
{:else if playlistQuery?.data}
{@const pl = playlistQuery.data}
{#if !editing}
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
{#if pl.cover_url}
<img src={pl.cover_url} alt="" class="h-full w-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
{#if pl.description}
<p class="mt-1 text-sm text-text-muted">{pl.description}</p>
{/if}
<p class="mt-2 text-xs text-text-muted">
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
{#if pl.is_public}· public{:else}· private{/if}
{#if !isOwner}· by {pl.owner_username}{/if}
</p>
</div>
{#if pl.refreshable && pl.system_variant}
<button
type="button"
disabled={refreshingSystem}
onclick={() => onRefreshSystem(pl.system_variant as string)}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
aria-label="Refresh playlist"
>
{refreshingSystem ? 'Refreshing…' : 'Refresh'}
</button>
{/if}
{#if isOwner && pl.is_public}
<button
type="button"
onclick={onCopyLink}
aria-label="Copy public link to this playlist"
title="Copy link"
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
>
<LinkIcon size={16} strokeWidth={1} />
</button>
{/if}
{#if isOwner}
<button
type="button"
onclick={startEdit}
aria-label="Edit playlist"
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
>
<Pencil size={16} strokeWidth={1} />
</button>
<button
type="button"
onclick={submitDelete}
aria-label="Delete playlist"
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-action-destructive"
>
<Trash2 size={16} strokeWidth={1} />
</button>
{/if}
</header>
{:else}
<section 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={editName}
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="mt-3 block text-sm font-medium text-text-primary">
Description
<textarea
bind:value={editDesc}
rows="2"
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
></textarea>
</label>
<label class="mt-3 flex items-center gap-2 text-sm text-text-primary">
<input type="checkbox" bind:checked={editIsPublic} class="rounded border-border" />
Make this playlist public
</label>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (editing = false)}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
>
Cancel
</button>
<button
type="button"
onclick={submitEdit}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
>
Save
</button>
</div>
</section>
{/if}
<section class="rounded-md border border-border bg-surface">
{#if pl.tracks.length === 0}
<p class="px-3 py-6 text-sm text-text-muted">
{#if isOwner}
No tracks yet. Add some via the "Add to playlist…" entry on any track row.
{:else}
No tracks in this playlist.
{/if}
</p>
{:else}
{#each pl.tracks as row (row.position)}
<PlaylistTrackRow
{row}
isOwner={isOwner ?? false}
onRemove={onRemove}
onPlay={onPlay}
onMove={onMove}
/>
{/each}
{/if}
</section>
{/if}
</div>