fix: detail page button layouts and AddToMenu positioning (#515)

* fix: detail page button layouts

playlist detail page:
- show edit/delete buttons on mobile (were hidden, only share showed)
- rename mobile-share-button to mobile-buttons for clarity

track detail page:
- remove bifurcated desktop layout (buttons on left/right sides)
- use centered button layout everywhere (like mobile had)
- simplify CSS by removing side-button-left/right styles

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: AddToMenu opens upward when near bottom of viewport

when the menu trigger is close to the bottom of the viewport,
detect available space and open the menu upward instead of downward.
this prevents the menu from being cut off by the player.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

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

authored by zzstoatzz.io Claude and committed by GitHub ae804bd3 31547edd

Changed files
+44 -43
frontend
src
lib
components
routes
playlist
track
+19 -1
frontend/src/lib/components/AddToMenu.svelte
··· 38 let playlists = $state<Playlist[]>([]); 39 let loadingPlaylists = $state(false); 40 let addingToPlaylist = $state<string | null>(null); 41 42 // filter out the excluded playlist (must be after playlists state declaration) 43 let filteredPlaylists = $derived( ··· 68 function toggleMenu(e: Event) { 69 e.stopPropagation(); 70 if (disabled) return; 71 menuOpen = !menuOpen; 72 if (!menuOpen) { 73 showPlaylistPicker = false; ··· 226 227 <div class="add-to-menu"> 228 <button 229 class="trigger-button" 230 class:liked 231 class:loading ··· 242 243 {#if menuOpen} 244 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 245 - <div class="menu-dropdown" role="menu" tabindex="-1" onclick={(e) => { 246 // don't stop propagation for links - let SvelteKit handle navigation 247 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 248 return; ··· 427 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 428 overflow: hidden; 429 z-index: 10; 430 } 431 432 .menu-item {
··· 38 let playlists = $state<Playlist[]>([]); 39 let loadingPlaylists = $state(false); 40 let addingToPlaylist = $state<string | null>(null); 41 + let openUpward = $state(false); 42 + let triggerRef = $state<HTMLButtonElement | null>(null); 43 44 // filter out the excluded playlist (must be after playlists state declaration) 45 let filteredPlaylists = $derived( ··· 70 function toggleMenu(e: Event) { 71 e.stopPropagation(); 72 if (disabled) return; 73 + 74 + if (!menuOpen && triggerRef) { 75 + // check if there's enough space below (accounting for player height ~150px) 76 + const rect = triggerRef.getBoundingClientRect(); 77 + const spaceBelow = window.innerHeight - rect.bottom; 78 + const menuHeight = 300; // approximate menu height 79 + const playerHeight = 150; // approximate player height 80 + openUpward = spaceBelow < menuHeight + playerHeight; 81 + } 82 + 83 menuOpen = !menuOpen; 84 if (!menuOpen) { 85 showPlaylistPicker = false; ··· 238 239 <div class="add-to-menu"> 240 <button 241 + bind:this={triggerRef} 242 class="trigger-button" 243 class:liked 244 class:loading ··· 255 256 {#if menuOpen} 257 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 258 + <div class="menu-dropdown" class:open-upward={openUpward} role="menu" tabindex="-1" onclick={(e) => { 259 // don't stop propagation for links - let SvelteKit handle navigation 260 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 261 return; ··· 440 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 441 overflow: hidden; 442 z-index: 10; 443 + } 444 + 445 + .menu-dropdown.open-upward { 446 + top: auto; 447 + bottom: calc(100% + 4px); 448 } 449 450 .menu-item {
+22 -4
frontend/src/routes/playlist/[id]/+page.svelte
··· 596 </button> 597 {/if} 598 {/if} 599 - <div class="mobile-share-button"> 600 <ShareButton url={$page.url.href} title="share playlist" /> 601 </div> 602 </div> 603 ··· 920 padding-bottom: 0.5rem; 921 } 922 923 - .mobile-share-button { 924 display: none; 925 } 926 ··· 1607 display: none; 1608 } 1609 1610 - .mobile-share-button { 1611 display: flex; 1612 - width: 100%; 1613 justify-content: center; 1614 } 1615 1616 .playlist-title {
··· 596 </button> 597 {/if} 598 {/if} 599 + <div class="mobile-buttons"> 600 <ShareButton url={$page.url.href} title="share playlist" /> 601 + {#if isOwner} 602 + <button class="icon-btn" onclick={openEditModal} aria-label="edit playlist metadata" title="edit playlist metadata"> 603 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 604 + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 605 + <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 606 + </svg> 607 + </button> 608 + <button class="icon-btn danger" onclick={() => showDeleteConfirm = true} aria-label="delete playlist" title="delete playlist"> 609 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 610 + <polyline points="3 6 5 6 21 6"></polyline> 611 + <path d="m19 6-.867 12.142A2 2 0 0 1 16.138 20H7.862a2 2 0 0 1-1.995-1.858L5 6"></path> 612 + <path d="M10 11v6"></path> 613 + <path d="M14 11v6"></path> 614 + <path d="m9 6 .5-2h5l.5 2"></path> 615 + </svg> 616 + </button> 617 + {/if} 618 </div> 619 </div> 620 ··· 937 padding-bottom: 0.5rem; 938 } 939 940 + .mobile-buttons { 941 display: none; 942 } 943 ··· 1624 display: none; 1625 } 1626 1627 + .mobile-buttons { 1628 display: flex; 1629 + gap: 0.5rem; 1630 justify-content: center; 1631 + align-items: center; 1632 } 1633 1634 .playlist-title {
+3 -38
frontend/src/routes/track/[id]/+page.svelte
··· 385 386 <!-- track info wrapper --> 387 <div class="track-info-wrapper"> 388 - <div class="side-button-left"> 389 - {#if auth.isAuthenticated} 390 - <AddToMenu 391 - trackId={track.id} 392 - trackTitle={track.title} 393 - trackUri={track.atproto_record_uri} 394 - trackCid={track.atproto_record_cid} 395 - initialLiked={track.is_liked || false} 396 - /> 397 - {/if} 398 - </div> 399 - 400 <div class="track-info"> 401 <h1 class="track-title">{track.title}</h1> 402 <div class="track-metadata"> ··· 443 {/if} 444 </div> 445 446 - <div class="mobile-side-buttons"> 447 {#if auth.isAuthenticated} 448 <AddToMenu 449 trackId={track.id} ··· 482 add to queue 483 </button> 484 </div> 485 - </div> 486 - 487 - <div class="side-button-right"> 488 - <ShareButton url={shareUrl} title="share track" /> 489 </div> 490 </div> 491 </div> ··· 671 max-width: 600px; 672 display: flex; 673 align-items: flex-start; 674 - gap: 1rem; 675 justify-content: center; 676 } 677 678 - .side-button-left, 679 - .side-button-right { 680 - flex-shrink: 0; 681 - display: flex; 682 - align-items: center; 683 - justify-content: center; 684 - padding-top: 0.5rem; 685 - } 686 - 687 .track-info { 688 flex: 1; 689 min-width: 0; ··· 830 color: var(--accent-hover); 831 } 832 833 - .mobile-side-buttons { 834 - display: none; 835 gap: 0.75rem; 836 justify-content: center; 837 align-items: center; ··· 916 flex-direction: column; 917 align-items: center; 918 gap: 0.75rem; 919 - } 920 - 921 - .side-button-left, 922 - .side-button-right { 923 - display: none; 924 - } 925 - 926 - .mobile-side-buttons { 927 - display: flex; 928 } 929 930 .track-info {
··· 385 386 <!-- track info wrapper --> 387 <div class="track-info-wrapper"> 388 <div class="track-info"> 389 <h1 class="track-title">{track.title}</h1> 390 <div class="track-metadata"> ··· 431 {/if} 432 </div> 433 434 + <div class="side-buttons"> 435 {#if auth.isAuthenticated} 436 <AddToMenu 437 trackId={track.id} ··· 470 add to queue 471 </button> 472 </div> 473 </div> 474 </div> 475 </div> ··· 655 max-width: 600px; 656 display: flex; 657 align-items: flex-start; 658 justify-content: center; 659 } 660 661 .track-info { 662 flex: 1; 663 min-width: 0; ··· 804 color: var(--accent-hover); 805 } 806 807 + .side-buttons { 808 + display: flex; 809 gap: 0.75rem; 810 justify-content: center; 811 align-items: center; ··· 890 flex-direction: column; 891 align-items: center; 892 gap: 0.75rem; 893 } 894 895 .track-info {