feat(web): copy-link button on owned public playlists (Tier C12)
test-web / test (push) Successful in 31s
test-web / test (push) Successful in 31s
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.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { Pencil, Trash2 } from 'lucide-svelte';
|
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import PlaylistTrackRow from '$lib/components/PlaylistTrackRow.svelte';
|
import PlaylistTrackRow from '$lib/components/PlaylistTrackRow.svelte';
|
||||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
@@ -130,6 +130,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) ---
|
// --- System-playlist refresh (#411 R2: generic by-kind) ---
|
||||||
let refreshingSystem = $state(false);
|
let refreshingSystem = $state(false);
|
||||||
|
|
||||||
@@ -188,6 +200,17 @@
|
|||||||
{refreshingSystem ? 'Refreshing…' : 'Refresh'}
|
{refreshingSystem ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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}
|
{#if isOwner}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -78,6 +78,32 @@ describe('Playlist detail page', () => {
|
|||||||
expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Copy-link button appears only when owner AND playlist is public', async () => {
|
||||||
|
// Owner + private → no button
|
||||||
|
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||||||
|
const { unmount } = render(PlaylistDetailPage);
|
||||||
|
expect(screen.queryByLabelText(/copy public link/i)).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Owner + public → button appears
|
||||||
|
mockedQuery.mockReturnValue(mockQuery({ data: { ...ownDetail, is_public: true } }));
|
||||||
|
render(PlaylistDetailPage);
|
||||||
|
expect(screen.getByLabelText(/copy public link/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Copy-link writes the absolute /playlists/[id] URL to clipboard', async () => {
|
||||||
|
mockedQuery.mockReturnValue(mockQuery({ data: { ...ownDetail, is_public: true } }));
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||||
|
render(PlaylistDetailPage);
|
||||||
|
await fireEvent.click(screen.getByLabelText(/copy public link/i));
|
||||||
|
// jsdom's window.location.origin is "http://localhost" by default.
|
||||||
|
// Match by suffix to stay resilient to vitest config changes.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/\/playlists\/p1$/))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Delete button confirms then deletes', async () => {
|
test('Delete button confirms then deletes', async () => {
|
||||||
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||||||
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
|
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user