feat(web/playlists): Discover refresh button (detail + home kebab)
Operator can refresh their Discover playlist without waiting for the next daily cron tick. Two surfaces, both gated on system_variant === 'discover': - Detail page header: "Refresh" button next to the cover art / meta. - Home Playlists row tile kebab: "Refresh Discover" menu item. Both call POST /api/playlists/system/discover/refresh, show a confirmation toast, and invalidate the playlist + playlists queries so the new content lands without a manual page reload. Extends Playlist.system_variant union with 'discover'. Adds refreshDiscover() typed client. Tests cover client behaviour (success + empty-library) plus conditional rendering on both surfaces.
This commit is contained in:
@@ -11,7 +11,8 @@
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
removePlaylistTrack,
|
||||
reorderPlaylist
|
||||
reorderPlaylist,
|
||||
refreshDiscover
|
||||
} from '$lib/api/playlists';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
@@ -135,12 +136,49 @@
|
||||
alert(copyForCode(code));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Toast (detail page) ---
|
||||
let toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => { toast = null; }, 5000);
|
||||
}
|
||||
|
||||
// --- Discover refresh ---
|
||||
let refreshingDiscover = $state(false);
|
||||
|
||||
async function onRefreshDiscover() {
|
||||
refreshingDiscover = true;
|
||||
try {
|
||||
await refreshDiscover();
|
||||
showToast('Discover refreshed.');
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||||
} catch (e: unknown) {
|
||||
showToast(`Refresh failed: ${(e as { code?: string })?.code ?? 'unknown'}`);
|
||||
} finally {
|
||||
refreshingDiscover = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle(playlistQuery?.data?.name ? `Playlist · ${playlistQuery.data.name}` : 'Playlist')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow-md"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-6">
|
||||
{#if playlistQuery?.isPending}
|
||||
<p class="text-text-muted">Loading…</p>
|
||||
@@ -166,6 +204,17 @@
|
||||
{#if !isOwner}· by {pl.owner_username}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if pl.system_variant === 'discover'}
|
||||
<button
|
||||
type="button"
|
||||
disabled={refreshingDiscover}
|
||||
onclick={onRefreshDiscover}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||
aria-label="Refresh Discover"
|
||||
>
|
||||
{refreshingDiscover ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -17,7 +17,8 @@ vi.mock('$lib/api/playlists', () => ({
|
||||
updatePlaylist: vi.fn().mockResolvedValue(undefined),
|
||||
deletePlaylist: vi.fn().mockResolvedValue(undefined),
|
||||
removePlaylistTrack: vi.fn().mockResolvedValue(undefined),
|
||||
reorderPlaylist: vi.fn().mockResolvedValue(undefined)
|
||||
reorderPlaylist: vi.fn().mockResolvedValue(undefined),
|
||||
refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p1', track_count: 5 })
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
@@ -50,7 +51,7 @@ vi.mock('$lib/api/quarantine', () => ({
|
||||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
||||
|
||||
import PlaylistDetailPage from './+page.svelte';
|
||||
import { createPlaylistQuery, deletePlaylist } from '$lib/api/playlists';
|
||||
import { createPlaylistQuery, deletePlaylist, refreshDiscover } from '$lib/api/playlists';
|
||||
|
||||
const mockedQuery = createPlaylistQuery as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -92,4 +93,44 @@ describe('Playlist detail page', () => {
|
||||
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('renders Refresh button only on Discover playlists', () => {
|
||||
const discoverDetail: PlaylistDetail = {
|
||||
...ownDetail,
|
||||
kind: 'system',
|
||||
system_variant: 'discover'
|
||||
};
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: discoverDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.getByLabelText(/refresh discover/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render Refresh button on for_you playlists', () => {
|
||||
const forYouDetail: PlaylistDetail = {
|
||||
...ownDetail,
|
||||
kind: 'system',
|
||||
system_variant: 'for_you'
|
||||
};
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: forYouDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.queryByLabelText(/refresh discover/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render Refresh button on user playlists', () => {
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
expect(screen.queryByLabelText(/refresh discover/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Refresh button calls refreshDiscover when clicked', async () => {
|
||||
const discoverDetail: PlaylistDetail = {
|
||||
...ownDetail,
|
||||
kind: 'system',
|
||||
system_variant: 'discover'
|
||||
};
|
||||
mockedQuery.mockReturnValue(mockQuery({ data: discoverDetail }));
|
||||
render(PlaylistDetailPage);
|
||||
await fireEvent.click(screen.getByLabelText(/refresh discover/i));
|
||||
await waitFor(() => expect(refreshDiscover).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user