fix: inline playlist creation to avoid navigation-based playback interruption (#510)

the create playlist link was navigating to /library?create=playlist, which
caused the layout to reinitialize and destroy the audio element. this fix
adds inline playlist creation to AddToMenu and TrackActionsMenu, allowing
users to create playlists and add tracks without leaving the current page.

changes:
- AddToMenu: replace link with inline create form that creates playlist
and adds track in one action
- TrackActionsMenu: same inline create form treatment
- portal: update empty state link to just go to /library (no query param)
- library page: remove query param handling (no longer needed)
- library +page.ts: use SvelteKit's fetch instead of window.fetch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 2b33a5fd 12c76e63

Changed files
+411 -135
frontend
+193 -51
frontend/src/lib/components/AddToMenu.svelte
··· 32 32 let loading = $state(false); 33 33 let menuOpen = $state(false); 34 34 let showPlaylistPicker = $state(false); 35 + let showCreateForm = $state(false); 36 + let newPlaylistName = $state(''); 37 + let creatingPlaylist = $state(false); 35 38 let playlists = $state<Playlist[]>([]); 36 39 let loadingPlaylists = $state(false); 37 40 let addingToPlaylist = $state<string | null>(null); ··· 162 165 163 166 function goBack(e: Event) { 164 167 e.stopPropagation(); 165 - showPlaylistPicker = false; 168 + if (showCreateForm) { 169 + showCreateForm = false; 170 + newPlaylistName = ''; 171 + } else { 172 + showPlaylistPicker = false; 173 + } 174 + } 175 + 176 + async function createPlaylist(e: Event) { 177 + e.stopPropagation(); 178 + if (!newPlaylistName.trim() || !trackUri || !trackCid) return; 179 + 180 + creatingPlaylist = true; 181 + try { 182 + // create the playlist 183 + const createResponse = await fetch(`${API_URL}/lists/playlists`, { 184 + method: 'POST', 185 + credentials: 'include', 186 + headers: { 'Content-Type': 'application/json' }, 187 + body: JSON.stringify({ name: newPlaylistName.trim() }) 188 + }); 189 + 190 + if (!createResponse.ok) { 191 + const data = await createResponse.json().catch(() => ({})); 192 + throw new Error(data.detail || 'failed to create playlist'); 193 + } 194 + 195 + const playlist = await createResponse.json(); 196 + 197 + // add the track to the new playlist 198 + const addResponse = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 199 + method: 'POST', 200 + credentials: 'include', 201 + headers: { 'Content-Type': 'application/json' }, 202 + body: JSON.stringify({ 203 + track_uri: trackUri, 204 + track_cid: trackCid 205 + }) 206 + }); 207 + 208 + if (addResponse.ok) { 209 + toast.success(`created "${playlist.name}" and added track`); 210 + } else { 211 + toast.success(`created "${playlist.name}"`); 212 + } 213 + 214 + // reset and close 215 + newPlaylistName = ''; 216 + showCreateForm = false; 217 + showPlaylistPicker = false; 218 + menuOpen = false; 219 + } catch (err) { 220 + toast.error(err instanceof Error ? err.message : 'failed to create playlist'); 221 + } finally { 222 + creatingPlaylist = false; 223 + } 166 224 } 167 225 </script> 168 226 ··· 222 280 </svg> 223 281 <span>back</span> 224 282 </button> 225 - <div class="playlist-list"> 226 - {#if loadingPlaylists} 227 - <div class="loading-state"> 228 - <span class="spinner"></span> 229 - <span>loading playlists...</span> 230 - </div> 231 - {:else if filteredPlaylists.length === 0} 232 - <div class="empty-state"> 233 - <span>no playlists yet</span> 234 - </div> 235 - {:else} 236 - {#each filteredPlaylists as playlist} 237 - <button 238 - class="playlist-item" 239 - onclick={(e) => addToPlaylist(playlist, e)} 240 - disabled={addingToPlaylist === playlist.id} 241 - > 242 - {#if playlist.image_url} 243 - <img src={playlist.image_url} alt="" class="playlist-thumb" /> 244 - {:else} 245 - <div class="playlist-thumb-placeholder"> 246 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 247 - <line x1="8" y1="6" x2="21" y2="6"></line> 248 - <line x1="8" y1="12" x2="21" y2="12"></line> 249 - <line x1="8" y1="18" x2="21" y2="18"></line> 250 - <line x1="3" y1="6" x2="3.01" y2="6"></line> 251 - <line x1="3" y1="12" x2="3.01" y2="12"></line> 252 - <line x1="3" y1="18" x2="3.01" y2="18"></line> 253 - </svg> 254 - </div> 255 - {/if} 256 - <span class="playlist-name">{playlist.name}</span> 257 - {#if addingToPlaylist === playlist.id} 258 - <span class="spinner small"></span> 259 - {/if} 260 - </button> 261 - {/each} 262 - {/if} 263 - <a href="/library?create=playlist" class="create-playlist-link" onclick={() => { menuOpen = false; showPlaylistPicker = false; }}> 264 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 265 - <line x1="12" y1="5" x2="12" y2="19"></line> 266 - <line x1="5" y1="12" x2="19" y2="12"></line> 267 - </svg> 268 - <span>create new playlist</span> 269 - </a> 270 - </div> 283 + {#if showCreateForm} 284 + <div class="create-form"> 285 + <input 286 + type="text" 287 + bind:value={newPlaylistName} 288 + placeholder="playlist name" 289 + disabled={creatingPlaylist} 290 + onkeydown={(e) => { 291 + if (e.key === 'Enter' && newPlaylistName.trim()) { 292 + createPlaylist(e); 293 + } 294 + }} 295 + /> 296 + <button 297 + class="create-btn" 298 + onclick={createPlaylist} 299 + disabled={creatingPlaylist || !newPlaylistName.trim()} 300 + > 301 + {#if creatingPlaylist} 302 + <span class="spinner small"></span> 303 + {:else} 304 + create & add 305 + {/if} 306 + </button> 307 + </div> 308 + {:else} 309 + <div class="playlist-list"> 310 + {#if loadingPlaylists} 311 + <div class="loading-state"> 312 + <span class="spinner"></span> 313 + <span>loading playlists...</span> 314 + </div> 315 + {:else if filteredPlaylists.length === 0} 316 + <div class="empty-state"> 317 + <span>no playlists yet</span> 318 + </div> 319 + {:else} 320 + {#each filteredPlaylists as playlist} 321 + <button 322 + class="playlist-item" 323 + onclick={(e) => addToPlaylist(playlist, e)} 324 + disabled={addingToPlaylist === playlist.id} 325 + > 326 + {#if playlist.image_url} 327 + <img src={playlist.image_url} alt="" class="playlist-thumb" /> 328 + {:else} 329 + <div class="playlist-thumb-placeholder"> 330 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 331 + <line x1="8" y1="6" x2="21" y2="6"></line> 332 + <line x1="8" y1="12" x2="21" y2="12"></line> 333 + <line x1="8" y1="18" x2="21" y2="18"></line> 334 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 335 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 336 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 337 + </svg> 338 + </div> 339 + {/if} 340 + <span class="playlist-name">{playlist.name}</span> 341 + {#if addingToPlaylist === playlist.id} 342 + <span class="spinner small"></span> 343 + {/if} 344 + </button> 345 + {/each} 346 + {/if} 347 + <button class="create-playlist-btn" onclick={(e) => { e.stopPropagation(); showCreateForm = true; }}> 348 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 349 + <line x1="12" y1="5" x2="12" y2="19"></line> 350 + <line x1="5" y1="12" x2="19" y2="12"></line> 351 + </svg> 352 + <span>create new playlist</span> 353 + </button> 354 + </div> 355 + {/if} 271 356 </div> 272 357 {/if} 273 358 </div> ··· 465 550 font-size: 0.85rem; 466 551 } 467 552 468 - .create-playlist-link { 553 + .create-playlist-btn { 554 + width: 100%; 469 555 display: flex; 470 556 align-items: center; 471 557 gap: 0.75rem; 472 558 padding: 0.625rem 1rem; 559 + background: transparent; 560 + border: none; 561 + border-top: 1px solid var(--border-subtle); 473 562 color: var(--accent); 474 563 font-size: 0.9rem; 475 564 font-family: inherit; 476 - text-decoration: none; 477 - border-top: 1px solid var(--border-subtle); 565 + cursor: pointer; 478 566 transition: background 0.15s; 567 + text-align: left; 479 568 } 480 569 481 - .create-playlist-link:hover { 570 + .create-playlist-btn:hover { 571 + background: var(--bg-tertiary); 572 + } 573 + 574 + .create-form { 575 + display: flex; 576 + flex-direction: column; 577 + gap: 0.75rem; 578 + padding: 1rem; 579 + } 580 + 581 + .create-form input { 582 + width: 100%; 583 + padding: 0.625rem 0.75rem; 482 584 background: var(--bg-tertiary); 585 + border: 1px solid var(--border-default); 586 + border-radius: 6px; 587 + color: var(--text-primary); 588 + font-family: inherit; 589 + font-size: 0.9rem; 590 + } 591 + 592 + .create-form input:focus { 593 + outline: none; 594 + border-color: var(--accent); 595 + } 596 + 597 + .create-form input::placeholder { 598 + color: var(--text-muted); 599 + } 600 + 601 + .create-form .create-btn { 602 + display: flex; 603 + align-items: center; 604 + justify-content: center; 605 + gap: 0.5rem; 606 + padding: 0.625rem 1rem; 607 + background: var(--accent); 608 + border: none; 609 + border-radius: 6px; 610 + color: white; 611 + font-family: inherit; 612 + font-size: 0.9rem; 613 + font-weight: 500; 614 + cursor: pointer; 615 + transition: opacity 0.15s; 616 + } 617 + 618 + .create-form .create-btn:hover:not(:disabled) { 619 + opacity: 0.9; 620 + } 621 + 622 + .create-form .create-btn:disabled { 623 + opacity: 0.5; 624 + cursor: not-allowed; 483 625 } 484 626 485 627 .spinner {
+192 -52
frontend/src/lib/components/TrackActionsMenu.svelte
··· 32 32 33 33 let showMenu = $state(false); 34 34 let showPlaylistPicker = $state(false); 35 + let showCreateForm = $state(false); 36 + let newPlaylistName = $state(''); 37 + let creatingPlaylist = $state(false); 35 38 let liked = $state(initialLiked); 36 39 let loading = $state(false); 37 40 let playlists = $state<Playlist[]>([]); ··· 59 62 function closeMenu() { 60 63 showMenu = false; 61 64 showPlaylistPicker = false; 65 + showCreateForm = false; 66 + newPlaylistName = ''; 62 67 } 63 68 64 69 function handleQueue(e: Event) { ··· 173 178 174 179 function goBack(e: Event) { 175 180 e.stopPropagation(); 176 - showPlaylistPicker = false; 181 + if (showCreateForm) { 182 + showCreateForm = false; 183 + newPlaylistName = ''; 184 + } else { 185 + showPlaylistPicker = false; 186 + } 187 + } 188 + 189 + async function createPlaylist(e: Event) { 190 + e.stopPropagation(); 191 + if (!newPlaylistName.trim() || !trackUri || !trackCid) return; 192 + 193 + creatingPlaylist = true; 194 + try { 195 + // create the playlist 196 + const createResponse = await fetch(`${API_URL}/lists/playlists`, { 197 + method: 'POST', 198 + credentials: 'include', 199 + headers: { 'Content-Type': 'application/json' }, 200 + body: JSON.stringify({ name: newPlaylistName.trim() }) 201 + }); 202 + 203 + if (!createResponse.ok) { 204 + const data = await createResponse.json().catch(() => ({})); 205 + throw new Error(data.detail || 'failed to create playlist'); 206 + } 207 + 208 + const playlist = await createResponse.json(); 209 + 210 + // add the track to the new playlist 211 + const addResponse = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 212 + method: 'POST', 213 + credentials: 'include', 214 + headers: { 'Content-Type': 'application/json' }, 215 + body: JSON.stringify({ 216 + track_uri: trackUri, 217 + track_cid: trackCid 218 + }) 219 + }); 220 + 221 + if (addResponse.ok) { 222 + toast.success(`created "${playlist.name}" and added track`); 223 + } else { 224 + toast.success(`created "${playlist.name}"`); 225 + } 226 + 227 + closeMenu(); 228 + } catch (err) { 229 + toast.error(err instanceof Error ? err.message : 'failed to create playlist'); 230 + } finally { 231 + creatingPlaylist = false; 232 + } 177 233 } 178 234 </script> 179 235 ··· 250 306 </svg> 251 307 <span>back</span> 252 308 </button> 253 - <div class="playlist-list"> 254 - {#if loadingPlaylists} 255 - <div class="loading-state"> 256 - <span class="spinner"></span> 257 - <span>loading...</span> 258 - </div> 259 - {:else if filteredPlaylists.length === 0} 260 - <div class="empty-state"> 261 - <span>no playlists</span> 262 - </div> 263 - {:else} 264 - {#each filteredPlaylists as playlist} 265 - <button 266 - class="playlist-item" 267 - onclick={(e) => addToPlaylist(playlist, e)} 268 - disabled={addingToPlaylist === playlist.id} 269 - > 270 - {#if playlist.image_url} 271 - <img src={playlist.image_url} alt="" class="playlist-thumb" /> 272 - {:else} 273 - <div class="playlist-thumb-placeholder"> 274 - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 275 - <line x1="8" y1="6" x2="21" y2="6"></line> 276 - <line x1="8" y1="12" x2="21" y2="12"></line> 277 - <line x1="8" y1="18" x2="21" y2="18"></line> 278 - <line x1="3" y1="6" x2="3.01" y2="6"></line> 279 - <line x1="3" y1="12" x2="3.01" y2="12"></line> 280 - <line x1="3" y1="18" x2="3.01" y2="18"></line> 281 - </svg> 282 - </div> 283 - {/if} 284 - <span class="playlist-name">{playlist.name}</span> 285 - {#if addingToPlaylist === playlist.id} 286 - <span class="spinner small"></span> 287 - {/if} 288 - </button> 289 - {/each} 290 - {/if} 291 - <a href="/library?create=playlist" class="create-playlist-link" onclick={closeMenu}> 292 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 293 - <line x1="12" y1="5" x2="12" y2="19"></line> 294 - <line x1="5" y1="12" x2="19" y2="12"></line> 295 - </svg> 296 - <span>create new playlist</span> 297 - </a> 298 - </div> 309 + {#if showCreateForm} 310 + <div class="create-form"> 311 + <input 312 + type="text" 313 + bind:value={newPlaylistName} 314 + placeholder="playlist name" 315 + disabled={creatingPlaylist} 316 + onkeydown={(e) => { 317 + if (e.key === 'Enter' && newPlaylistName.trim()) { 318 + createPlaylist(e); 319 + } 320 + }} 321 + /> 322 + <button 323 + class="create-btn" 324 + onclick={createPlaylist} 325 + disabled={creatingPlaylist || !newPlaylistName.trim()} 326 + > 327 + {#if creatingPlaylist} 328 + <span class="spinner small"></span> 329 + {:else} 330 + create & add 331 + {/if} 332 + </button> 333 + </div> 334 + {:else} 335 + <div class="playlist-list"> 336 + {#if loadingPlaylists} 337 + <div class="loading-state"> 338 + <span class="spinner"></span> 339 + <span>loading...</span> 340 + </div> 341 + {:else if filteredPlaylists.length === 0} 342 + <div class="empty-state"> 343 + <span>no playlists</span> 344 + </div> 345 + {:else} 346 + {#each filteredPlaylists as playlist} 347 + <button 348 + class="playlist-item" 349 + onclick={(e) => addToPlaylist(playlist, e)} 350 + disabled={addingToPlaylist === playlist.id} 351 + > 352 + {#if playlist.image_url} 353 + <img src={playlist.image_url} alt="" class="playlist-thumb" /> 354 + {:else} 355 + <div class="playlist-thumb-placeholder"> 356 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 357 + <line x1="8" y1="6" x2="21" y2="6"></line> 358 + <line x1="8" y1="12" x2="21" y2="12"></line> 359 + <line x1="8" y1="18" x2="21" y2="18"></line> 360 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 361 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 362 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 363 + </svg> 364 + </div> 365 + {/if} 366 + <span class="playlist-name">{playlist.name}</span> 367 + {#if addingToPlaylist === playlist.id} 368 + <span class="spinner small"></span> 369 + {/if} 370 + </button> 371 + {/each} 372 + {/if} 373 + <button class="create-playlist-btn" onclick={(e) => { e.stopPropagation(); showCreateForm = true; }}> 374 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 375 + <line x1="12" y1="5" x2="12" y2="19"></line> 376 + <line x1="5" y1="12" x2="19" y2="12"></line> 377 + </svg> 378 + <span>create new playlist</span> 379 + </button> 380 + </div> 381 + {/if} 299 382 </div> 300 383 {/if} 301 384 </div> ··· 506 589 font-size: 0.9rem; 507 590 } 508 591 509 - .create-playlist-link { 592 + .create-playlist-btn { 593 + width: 100%; 510 594 display: flex; 511 595 align-items: center; 512 596 gap: 0.75rem; 513 597 padding: 0.875rem 1.25rem; 598 + background: transparent; 599 + border: none; 600 + border-top: 1px solid var(--border-subtle); 514 601 color: var(--accent); 515 602 font-size: 1rem; 516 603 font-family: inherit; 517 - text-decoration: none; 518 - border-top: 1px solid var(--border-subtle); 604 + cursor: pointer; 519 605 transition: background 0.15s; 606 + text-align: left; 520 607 } 521 608 522 - .create-playlist-link:hover, 523 - .create-playlist-link:active { 609 + .create-playlist-btn:hover, 610 + .create-playlist-btn:active { 524 611 background: var(--bg-tertiary); 612 + } 613 + 614 + .create-form { 615 + display: flex; 616 + flex-direction: column; 617 + gap: 0.75rem; 618 + padding: 1rem 1.25rem; 619 + } 620 + 621 + .create-form input { 622 + width: 100%; 623 + padding: 0.75rem 1rem; 624 + background: var(--bg-tertiary); 625 + border: 1px solid var(--border-default); 626 + border-radius: 8px; 627 + color: var(--text-primary); 628 + font-family: inherit; 629 + font-size: 1rem; 630 + } 631 + 632 + .create-form input:focus { 633 + outline: none; 634 + border-color: var(--accent); 635 + } 636 + 637 + .create-form input::placeholder { 638 + color: var(--text-muted); 639 + } 640 + 641 + .create-form .create-btn { 642 + display: flex; 643 + align-items: center; 644 + justify-content: center; 645 + gap: 0.5rem; 646 + padding: 0.75rem 1rem; 647 + background: var(--accent); 648 + border: none; 649 + border-radius: 8px; 650 + color: white; 651 + font-family: inherit; 652 + font-size: 1rem; 653 + font-weight: 500; 654 + cursor: pointer; 655 + transition: opacity 0.15s; 656 + } 657 + 658 + .create-form .create-btn:hover:not(:disabled) { 659 + opacity: 0.9; 660 + } 661 + 662 + .create-form .create-btn:disabled { 663 + opacity: 0.5; 664 + cursor: not-allowed; 525 665 } 526 666 527 667 .spinner {
+2 -6
frontend/src/lib/components/player/Player.svelte
··· 337 337 bind:currentTime={player.currentTime} 338 338 bind:duration={player.duration} 339 339 bind:volume={player.volume} 340 - onplay={() => { 341 - player.paused = false; 342 - }} 343 - onpause={() => { 344 - player.paused = true; 345 - }} 340 + onplay={() => player.paused = false} 341 + onpause={() => player.paused = true} 346 342 onended={handleTrackEnded} 347 343 ></audio> 348 344
+1
frontend/src/lib/player.svelte.ts
··· 6 6 currentTrack = $state<Track | null>(null); 7 7 audioElement = $state<HTMLAudioElement | undefined>(undefined); 8 8 paused = $state(true); 9 + 9 10 currentTime = $state(0); 10 11 duration = $state(0); 11 12 volume = $state(0.7);
-12
frontend/src/routes/library/+page.svelte
··· 2 2 import Header from '$lib/components/Header.svelte'; 3 3 import { auth } from '$lib/auth.svelte'; 4 4 import { goto } from '$app/navigation'; 5 - import { page } from '$app/stores'; 6 5 import { API_URL } from '$lib/config'; 7 6 import type { PageData } from './$types'; 8 7 import type { Playlist } from '$lib/types'; ··· 14 13 let newPlaylistName = $state(''); 15 14 let creating = $state(false); 16 15 let error = $state(''); 17 - 18 - // open create modal if ?create=playlist query param is present 19 - $effect(() => { 20 - if ($page.url.searchParams.get('create') === 'playlist') { 21 - showCreateModal = true; 22 - // clear the query param from URL without navigation 23 - const url = new URL($page.url); 24 - url.searchParams.delete('create'); 25 - window.history.replaceState({}, '', url.pathname); 26 - } 27 - }); 28 16 29 17 async function handleLogout() { 30 18 await auth.logout();
+22 -13
frontend/src/routes/library/+page.ts
··· 1 1 import { browser } from '$app/environment'; 2 2 import { redirect } from '@sveltejs/kit'; 3 - import { fetchLikedTracks } from '$lib/tracks.svelte'; 4 3 import { API_URL } from '$lib/config'; 5 4 import type { LoadEvent } from '@sveltejs/kit'; 6 - import type { Playlist } from '$lib/types'; 5 + import type { Playlist, Track } from '$lib/types'; 7 6 8 7 export interface PageData { 9 8 likedCount: number; ··· 12 11 13 12 export const ssr = false; 14 13 15 - async function fetchPlaylists(): Promise<Playlist[]> { 16 - const response = await fetch(`${API_URL}/lists/playlists`, { 17 - credentials: 'include' 18 - }); 19 - if (!response.ok) { 20 - throw new Error('failed to fetch playlists'); 21 - } 22 - return response.json(); 23 - } 24 - 25 - export async function load({ parent }: LoadEvent): Promise<PageData> { 14 + export async function load({ parent, fetch }: LoadEvent): Promise<PageData> { 26 15 if (!browser) { 27 16 return { likedCount: 0, playlists: [] }; 28 17 } ··· 31 20 const { isAuthenticated } = await parent(); 32 21 if (!isAuthenticated) { 33 22 throw redirect(302, '/'); 23 + } 24 + 25 + async function fetchLikedTracks(): Promise<Track[]> { 26 + const response = await fetch(`${API_URL}/tracks/liked`, { 27 + credentials: 'include' 28 + }); 29 + if (!response.ok) { 30 + throw new Error('failed to fetch liked tracks'); 31 + } 32 + return response.json(); 33 + } 34 + 35 + async function fetchPlaylists(): Promise<Playlist[]> { 36 + const response = await fetch(`${API_URL}/lists/playlists`, { 37 + credentials: 'include' 38 + }); 39 + if (!response.ok) { 40 + throw new Error('failed to fetch playlists'); 41 + } 42 + return response.json(); 34 43 } 35 44 36 45 try {
+1 -1
frontend/src/routes/portal/+page.svelte
··· 887 887 <WaveLoading size="lg" message="loading playlists..." /> 888 888 </div> 889 889 {:else if playlists.length === 0} 890 - <p class="empty">no playlists yet - <a href="/library?create=playlist">create a new playlist</a></p> 890 + <p class="empty">no playlists yet - <a href="/library">create a new playlist</a></p> 891 891 {:else} 892 892 <div class="playlists-grid"> 893 893 {#each playlists as playlist}