at main 18 kB view raw
1<script lang="ts"> 2 import { likeTrack, unlikeTrack } from '$lib/tracks.svelte'; 3 import { toast } from '$lib/toast.svelte'; 4 import { API_URL } from '$lib/config'; 5 import type { Playlist } from '$lib/types'; 6 7 interface Props { 8 trackId: number; 9 trackTitle: string; 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 gated?: boolean; 14 initialLiked: boolean; 15 shareUrl: string; 16 onQueue: () => void; 17 isAuthenticated: boolean; 18 likeDisabled?: boolean; 19 excludePlaylistId?: string; 20 } 21 22 let { 23 trackId, 24 trackTitle, 25 trackUri, 26 trackCid, 27 fileId, 28 gated, 29 initialLiked, 30 shareUrl, 31 onQueue, 32 isAuthenticated, 33 likeDisabled = false, 34 excludePlaylistId 35 }: Props = $props(); 36 37 let showMenu = $state(false); 38 let showPlaylistPicker = $state(false); 39 let showCreateForm = $state(false); 40 let newPlaylistName = $state(''); 41 let creatingPlaylist = $state(false); 42 let liked = $state(initialLiked); 43 let loading = $state(false); 44 let playlists = $state<Playlist[]>([]); 45 let loadingPlaylists = $state(false); 46 let addingToPlaylist = $state<string | null>(null); 47 48 // filter out the excluded playlist (must be after playlists state declaration) 49 let filteredPlaylists = $derived( 50 excludePlaylistId ? playlists.filter(p => p.id !== excludePlaylistId) : playlists 51 ); 52 53 // update liked state when initialLiked changes 54 $effect(() => { 55 liked = initialLiked; 56 }); 57 58 function toggleMenu(e: Event) { 59 e.stopPropagation(); 60 showMenu = !showMenu; 61 if (!showMenu) { 62 showPlaylistPicker = false; 63 } 64 } 65 66 function closeMenu() { 67 showMenu = false; 68 showPlaylistPicker = false; 69 showCreateForm = false; 70 newPlaylistName = ''; 71 } 72 73 function handleQueue(e: Event) { 74 e.stopPropagation(); 75 onQueue(); 76 closeMenu(); 77 } 78 79 async function handleShare(e: Event) { 80 e.stopPropagation(); 81 try { 82 await navigator.clipboard.writeText(shareUrl); 83 toast.success('link copied'); 84 closeMenu(); 85 } catch { 86 toast.error('failed to copy link'); 87 } 88 } 89 90 async function handleLike(e: Event) { 91 e.stopPropagation(); 92 93 if (loading || likeDisabled) { 94 if (likeDisabled) { 95 toast.error("track's record is unavailable"); 96 } 97 return; 98 } 99 100 loading = true; 101 const previousState = liked; 102 liked = !liked; 103 104 try { 105 const success = liked 106 ? await likeTrack(trackId, fileId, gated) 107 : await unlikeTrack(trackId); 108 109 if (!success) { 110 liked = previousState; 111 toast.error('failed to update like'); 112 } else { 113 if (liked) { 114 toast.success(`liked ${trackTitle}`); 115 } else { 116 toast.info(`unliked ${trackTitle}`); 117 } 118 } 119 closeMenu(); 120 } catch { 121 liked = previousState; 122 toast.error('failed to update like'); 123 } finally { 124 loading = false; 125 } 126 } 127 128 async function showPlaylists(e: Event) { 129 e.stopPropagation(); 130 if (!trackUri || !trackCid) { 131 toast.error('track cannot be added to playlists'); 132 return; 133 } 134 135 showPlaylistPicker = true; 136 if (playlists.length === 0) { 137 loadingPlaylists = true; 138 try { 139 const response = await fetch(`${API_URL}/lists/playlists`, { 140 credentials: 'include' 141 }); 142 if (response.ok) { 143 playlists = await response.json(); 144 } 145 } catch { 146 toast.error('failed to load playlists'); 147 } finally { 148 loadingPlaylists = false; 149 } 150 } 151 } 152 153 async function addToPlaylist(playlist: Playlist, e: Event) { 154 e.stopPropagation(); 155 if (!trackUri || !trackCid) return; 156 157 addingToPlaylist = playlist.id; 158 try { 159 const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 160 method: 'POST', 161 credentials: 'include', 162 headers: { 'Content-Type': 'application/json' }, 163 body: JSON.stringify({ 164 track_uri: trackUri, 165 track_cid: trackCid 166 }) 167 }); 168 169 if (response.ok) { 170 toast.success(`added to ${playlist.name}`); 171 closeMenu(); 172 } else { 173 const data = await response.json().catch(() => ({})); 174 toast.error(data.detail || 'failed to add to playlist'); 175 } 176 } catch { 177 toast.error('failed to add to playlist'); 178 } finally { 179 addingToPlaylist = null; 180 } 181 } 182 183 function goBack(e: Event) { 184 e.stopPropagation(); 185 if (showCreateForm) { 186 showCreateForm = false; 187 newPlaylistName = ''; 188 } else { 189 showPlaylistPicker = false; 190 } 191 } 192 193 async function createPlaylist(e: Event) { 194 e.stopPropagation(); 195 if (!newPlaylistName.trim() || !trackUri || !trackCid) return; 196 197 creatingPlaylist = true; 198 try { 199 // create the playlist 200 const createResponse = await fetch(`${API_URL}/lists/playlists`, { 201 method: 'POST', 202 credentials: 'include', 203 headers: { 'Content-Type': 'application/json' }, 204 body: JSON.stringify({ name: newPlaylistName.trim() }) 205 }); 206 207 if (!createResponse.ok) { 208 const data = await createResponse.json().catch(() => ({})); 209 throw new Error(data.detail || 'failed to create playlist'); 210 } 211 212 const playlist = await createResponse.json(); 213 214 // add the track to the new playlist 215 const addResponse = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 216 method: 'POST', 217 credentials: 'include', 218 headers: { 'Content-Type': 'application/json' }, 219 body: JSON.stringify({ 220 track_uri: trackUri, 221 track_cid: trackCid 222 }) 223 }); 224 225 if (addResponse.ok) { 226 toast.success(`created "${playlist.name}" and added track`); 227 } else { 228 toast.success(`created "${playlist.name}"`); 229 } 230 231 closeMenu(); 232 } catch (err) { 233 toast.error(err instanceof Error ? err.message : 'failed to create playlist'); 234 } finally { 235 creatingPlaylist = false; 236 } 237 } 238</script> 239 240<div class="actions-menu"> 241 <button class="menu-button" onclick={toggleMenu} title="actions"> 242 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 243 <circle cx="12" cy="5" r="1"></circle> 244 <circle cx="12" cy="12" r="1"></circle> 245 <circle cx="12" cy="19" r="1"></circle> 246 </svg> 247 </button> 248 249 {#if showMenu} 250 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 251 <div class="menu-backdrop" role="presentation" onclick={closeMenu}></div> 252 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 253 <div class="menu-panel" role="menu" tabindex="-1" onclick={(e) => { 254 // don't stop propagation for links - let SvelteKit handle navigation 255 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 256 return; 257 } 258 e.stopPropagation(); 259 }}> 260 {#if !showPlaylistPicker} 261 {#if isAuthenticated} 262 <button class="menu-item" onclick={handleLike} disabled={loading || likeDisabled} class:disabled={likeDisabled}> 263 <svg width="18" height="18" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 264 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 265 </svg> 266 <span>{liked ? 'remove from liked' : 'add to liked'}</span> 267 </button> 268 {#if trackUri && trackCid} 269 <button class="menu-item" onclick={showPlaylists}> 270 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 271 <line x1="8" y1="6" x2="21" y2="6"></line> 272 <line x1="8" y1="12" x2="21" y2="12"></line> 273 <line x1="8" y1="18" x2="21" y2="18"></line> 274 <line x1="3" y1="6" x2="3.01" y2="6"></line> 275 <line x1="3" y1="12" x2="3.01" y2="12"></line> 276 <line x1="3" y1="18" x2="3.01" y2="18"></line> 277 </svg> 278 <span>add to playlist</span> 279 <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 280 <path d="M9 18l6-6-6-6"/> 281 </svg> 282 </button> 283 {/if} 284 {/if} 285 <button class="menu-item" onclick={handleQueue}> 286 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 287 <line x1="5" y1="15" x2="5" y2="21"></line> 288 <line x1="2" y1="18" x2="8" y2="18"></line> 289 <line x1="9" y1="6" x2="21" y2="6"></line> 290 <line x1="9" y1="12" x2="21" y2="12"></line> 291 <line x1="9" y1="18" x2="21" y2="18"></line> 292 </svg> 293 <span>add to queue</span> 294 </button> 295 <button class="menu-item" onclick={handleShare}> 296 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 297 <circle cx="18" cy="5" r="3"></circle> 298 <circle cx="6" cy="12" r="3"></circle> 299 <circle cx="18" cy="19" r="3"></circle> 300 <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> 301 <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> 302 </svg> 303 <span>share</span> 304 </button> 305 {:else} 306 <div class="playlist-picker"> 307 <button class="back-button" onclick={goBack}> 308 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 309 <path d="M15 18l-6-6 6-6"/> 310 </svg> 311 <span>back</span> 312 </button> 313 {#if showCreateForm} 314 <div class="create-form"> 315 <input 316 type="text" 317 bind:value={newPlaylistName} 318 placeholder="playlist name" 319 disabled={creatingPlaylist} 320 onkeydown={(e) => { 321 if (e.key === 'Enter' && newPlaylistName.trim()) { 322 createPlaylist(e); 323 } 324 }} 325 /> 326 <button 327 class="create-btn" 328 onclick={createPlaylist} 329 disabled={creatingPlaylist || !newPlaylistName.trim()} 330 > 331 {#if creatingPlaylist} 332 <span class="spinner small"></span> 333 {:else} 334 create & add 335 {/if} 336 </button> 337 </div> 338 {:else} 339 <div class="playlist-list"> 340 {#if loadingPlaylists} 341 <div class="loading-state"> 342 <span class="spinner"></span> 343 <span>loading...</span> 344 </div> 345 {:else if filteredPlaylists.length === 0} 346 <div class="empty-state"> 347 <span>no playlists</span> 348 </div> 349 {:else} 350 {#each filteredPlaylists as playlist} 351 <button 352 class="playlist-item" 353 onclick={(e) => addToPlaylist(playlist, e)} 354 disabled={addingToPlaylist === playlist.id} 355 > 356 {#if playlist.image_url} 357 <img src={playlist.image_url} alt="" class="playlist-thumb" /> 358 {:else} 359 <div class="playlist-thumb-placeholder"> 360 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 361 <line x1="8" y1="6" x2="21" y2="6"></line> 362 <line x1="8" y1="12" x2="21" y2="12"></line> 363 <line x1="8" y1="18" x2="21" y2="18"></line> 364 <line x1="3" y1="6" x2="3.01" y2="6"></line> 365 <line x1="3" y1="12" x2="3.01" y2="12"></line> 366 <line x1="3" y1="18" x2="3.01" y2="18"></line> 367 </svg> 368 </div> 369 {/if} 370 <span class="playlist-name">{playlist.name}</span> 371 {#if addingToPlaylist === playlist.id} 372 <span class="spinner small"></span> 373 {/if} 374 </button> 375 {/each} 376 {/if} 377 <button class="create-playlist-btn" onclick={(e) => { e.stopPropagation(); showCreateForm = true; }}> 378 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 379 <line x1="12" y1="5" x2="12" y2="19"></line> 380 <line x1="5" y1="12" x2="19" y2="12"></line> 381 </svg> 382 <span>create new playlist</span> 383 </button> 384 </div> 385 {/if} 386 </div> 387 {/if} 388 </div> 389 {/if} 390</div> 391 392<style> 393 .actions-menu { 394 position: relative; 395 } 396 397 .menu-button { 398 width: 32px; 399 height: 32px; 400 display: flex; 401 align-items: center; 402 justify-content: center; 403 background: transparent; 404 border: 1px solid var(--border-default); 405 border-radius: var(--radius-sm); 406 color: var(--text-tertiary); 407 cursor: pointer; 408 transition: all 0.2s; 409 } 410 411 .menu-button:hover { 412 background: var(--bg-tertiary); 413 border-color: var(--accent); 414 color: var(--accent); 415 } 416 417 .menu-backdrop { 418 position: fixed; 419 top: 0; 420 left: 0; 421 right: 0; 422 bottom: 0; 423 z-index: 100; 424 background: rgba(0, 0, 0, 0.4); 425 } 426 427 .menu-panel { 428 position: fixed; 429 top: 0; 430 left: 0; 431 right: 0; 432 background: var(--bg-secondary); 433 border-radius: 0 0 16px 16px; 434 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); 435 z-index: 101; 436 animation: slideDown 0.2s ease-out; 437 padding-top: env(safe-area-inset-top, 0); 438 max-height: 70vh; 439 overflow-y: auto; 440 } 441 442 @keyframes slideDown { 443 from { 444 transform: translateY(-100%); 445 } 446 to { 447 transform: translateY(0); 448 } 449 } 450 451 .menu-item { 452 display: flex; 453 align-items: center; 454 gap: 0.75rem; 455 background: transparent; 456 border: none; 457 color: var(--text-primary); 458 cursor: pointer; 459 padding: 1rem 1.25rem; 460 transition: all 0.15s; 461 font-family: inherit; 462 width: 100%; 463 text-align: left; 464 border-bottom: 1px solid var(--border-subtle); 465 } 466 467 .menu-item:last-child { 468 border-bottom: none; 469 } 470 471 .menu-item:hover, 472 .menu-item:active { 473 background: var(--bg-tertiary); 474 } 475 476 .menu-item span { 477 font-size: var(--text-lg); 478 font-weight: 400; 479 flex: 1; 480 } 481 482 .menu-item svg { 483 width: 20px; 484 height: 20px; 485 flex-shrink: 0; 486 } 487 488 .menu-item .chevron { 489 color: var(--text-muted); 490 } 491 492 .menu-item:disabled { 493 opacity: 0.5; 494 cursor: not-allowed; 495 } 496 497 .playlist-picker { 498 display: flex; 499 flex-direction: column; 500 } 501 502 .back-button { 503 display: flex; 504 align-items: center; 505 gap: 0.5rem; 506 padding: 1rem 1.25rem; 507 background: transparent; 508 border: none; 509 border-bottom: 1px solid var(--border-default); 510 color: var(--text-secondary); 511 font-size: var(--text-base); 512 font-family: inherit; 513 cursor: pointer; 514 transition: background 0.15s; 515 } 516 517 .back-button:hover, 518 .back-button:active { 519 background: var(--bg-tertiary); 520 } 521 522 .playlist-list { 523 max-height: 50vh; 524 overflow-y: auto; 525 } 526 527 .playlist-item { 528 width: 100%; 529 display: flex; 530 align-items: center; 531 gap: 0.75rem; 532 padding: 0.875rem 1.25rem; 533 background: transparent; 534 border: none; 535 border-bottom: 1px solid var(--border-subtle); 536 color: var(--text-primary); 537 font-size: var(--text-lg); 538 font-family: inherit; 539 cursor: pointer; 540 transition: background 0.15s; 541 text-align: left; 542 } 543 544 .playlist-item:last-child { 545 border-bottom: none; 546 } 547 548 .playlist-item:hover, 549 .playlist-item:active { 550 background: var(--bg-tertiary); 551 } 552 553 .playlist-item:disabled { 554 opacity: 0.6; 555 } 556 557 .playlist-thumb, 558 .playlist-thumb-placeholder { 559 width: 36px; 560 height: 36px; 561 border-radius: var(--radius-sm); 562 flex-shrink: 0; 563 } 564 565 .playlist-thumb { 566 object-fit: cover; 567 } 568 569 .playlist-thumb-placeholder { 570 background: var(--bg-tertiary); 571 display: flex; 572 align-items: center; 573 justify-content: center; 574 color: var(--text-muted); 575 } 576 577 .playlist-name { 578 flex: 1; 579 min-width: 0; 580 overflow: hidden; 581 text-overflow: ellipsis; 582 white-space: nowrap; 583 } 584 585 .loading-state, 586 .empty-state { 587 display: flex; 588 align-items: center; 589 justify-content: center; 590 gap: 0.5rem; 591 padding: 2rem 1rem; 592 color: var(--text-tertiary); 593 font-size: var(--text-base); 594 } 595 596 .create-playlist-btn { 597 width: 100%; 598 display: flex; 599 align-items: center; 600 gap: 0.75rem; 601 padding: 0.875rem 1.25rem; 602 background: transparent; 603 border: none; 604 border-top: 1px solid var(--border-subtle); 605 color: var(--accent); 606 font-size: var(--text-lg); 607 font-family: inherit; 608 cursor: pointer; 609 transition: background 0.15s; 610 text-align: left; 611 } 612 613 .create-playlist-btn:hover, 614 .create-playlist-btn:active { 615 background: var(--bg-tertiary); 616 } 617 618 .create-form { 619 display: flex; 620 flex-direction: column; 621 gap: 0.75rem; 622 padding: 1rem 1.25rem; 623 } 624 625 .create-form input { 626 width: 100%; 627 padding: 0.75rem 1rem; 628 background: var(--bg-tertiary); 629 border: 1px solid var(--border-default); 630 border-radius: var(--radius-md); 631 color: var(--text-primary); 632 font-family: inherit; 633 font-size: var(--text-lg); 634 } 635 636 .create-form input:focus { 637 outline: none; 638 border-color: var(--accent); 639 } 640 641 .create-form input::placeholder { 642 color: var(--text-muted); 643 } 644 645 .create-form .create-btn { 646 display: flex; 647 align-items: center; 648 justify-content: center; 649 gap: 0.5rem; 650 padding: 0.75rem 1rem; 651 background: var(--accent); 652 border: none; 653 border-radius: var(--radius-md); 654 color: white; 655 font-family: inherit; 656 font-size: var(--text-lg); 657 font-weight: 500; 658 cursor: pointer; 659 transition: opacity 0.15s; 660 } 661 662 .create-form .create-btn:hover:not(:disabled) { 663 opacity: 0.9; 664 } 665 666 .create-form .create-btn:disabled { 667 opacity: 0.5; 668 cursor: not-allowed; 669 } 670 671 .spinner { 672 width: 18px; 673 height: 18px; 674 border: 2px solid var(--border-default); 675 border-top-color: var(--accent); 676 border-radius: var(--radius-full); 677 animation: spin 0.8s linear infinite; 678 } 679 680 .spinner.small { 681 width: 16px; 682 height: 16px; 683 } 684 685 @keyframes spin { 686 to { transform: rotate(360deg); } 687 } 688 689 /* desktop: show as dropdown instead of bottom sheet */ 690 @media (min-width: 769px) { 691 .menu-backdrop { 692 background: transparent; 693 } 694 695 .menu-panel { 696 position: absolute; 697 bottom: auto; 698 left: auto; 699 right: 100%; 700 top: 50%; 701 transform: translateY(-50%); 702 margin-right: 0.5rem; 703 border-radius: var(--radius-md); 704 min-width: 180px; 705 max-height: none; 706 animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); 707 padding-bottom: 0; 708 } 709 710 @keyframes slideIn { 711 from { 712 opacity: 0; 713 transform: translateY(-50%) scale(0.95); 714 } 715 to { 716 opacity: 1; 717 transform: translateY(-50%) scale(1); 718 } 719 } 720 721 .menu-item { 722 padding: 0.75rem 1rem; 723 } 724 725 .menu-item span { 726 font-size: var(--text-base); 727 } 728 729 .menu-item svg { 730 width: 18px; 731 height: 18px; 732 } 733 734 .back-button { 735 padding: 0.75rem 1rem; 736 } 737 738 .playlist-item { 739 padding: 0.625rem 1rem; 740 font-size: var(--text-base); 741 } 742 743 .playlist-thumb, 744 .playlist-thumb-placeholder { 745 width: 32px; 746 height: 32px; 747 } 748 749 .playlist-list { 750 max-height: 200px; 751 } 752 753 .loading-state, 754 .empty-state { 755 padding: 1.5rem 1rem; 756 } 757 } 758</style>