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 38 let playlists = $state<Playlist[]>([]); 39 39 let loadingPlaylists = $state(false); 40 40 let addingToPlaylist = $state<string | null>(null); 41 + let openUpward = $state(false); 42 + let triggerRef = $state<HTMLButtonElement | null>(null); 41 43 42 44 // filter out the excluded playlist (must be after playlists state declaration) 43 45 let filteredPlaylists = $derived( ··· 68 70 function toggleMenu(e: Event) { 69 71 e.stopPropagation(); 70 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 + 71 83 menuOpen = !menuOpen; 72 84 if (!menuOpen) { 73 85 showPlaylistPicker = false; ··· 226 238 227 239 <div class="add-to-menu"> 228 240 <button 241 + bind:this={triggerRef} 229 242 class="trigger-button" 230 243 class:liked 231 244 class:loading ··· 242 255 243 256 {#if menuOpen} 244 257 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 245 - <div class="menu-dropdown" role="menu" tabindex="-1" onclick={(e) => { 258 + <div class="menu-dropdown" class:open-upward={openUpward} role="menu" tabindex="-1" onclick={(e) => { 246 259 // don't stop propagation for links - let SvelteKit handle navigation 247 260 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 248 261 return; ··· 427 440 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 428 441 overflow: hidden; 429 442 z-index: 10; 443 + } 444 + 445 + .menu-dropdown.open-upward { 446 + top: auto; 447 + bottom: calc(100% + 4px); 430 448 } 431 449 432 450 .menu-item {
+22 -4
frontend/src/routes/playlist/[id]/+page.svelte
··· 596 596 </button> 597 597 {/if} 598 598 {/if} 599 - <div class="mobile-share-button"> 599 + <div class="mobile-buttons"> 600 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} 601 618 </div> 602 619 </div> 603 620 ··· 920 937 padding-bottom: 0.5rem; 921 938 } 922 939 923 - .mobile-share-button { 940 + .mobile-buttons { 924 941 display: none; 925 942 } 926 943 ··· 1607 1624 display: none; 1608 1625 } 1609 1626 1610 - .mobile-share-button { 1627 + .mobile-buttons { 1611 1628 display: flex; 1612 - width: 100%; 1629 + gap: 0.5rem; 1613 1630 justify-content: center; 1631 + align-items: center; 1614 1632 } 1615 1633 1616 1634 .playlist-title {
+3 -38
frontend/src/routes/track/[id]/+page.svelte
··· 385 385 386 386 <!-- track info wrapper --> 387 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 388 <div class="track-info"> 401 389 <h1 class="track-title">{track.title}</h1> 402 390 <div class="track-metadata"> ··· 443 431 {/if} 444 432 </div> 445 433 446 - <div class="mobile-side-buttons"> 434 + <div class="side-buttons"> 447 435 {#if auth.isAuthenticated} 448 436 <AddToMenu 449 437 trackId={track.id} ··· 482 470 add to queue 483 471 </button> 484 472 </div> 485 - </div> 486 - 487 - <div class="side-button-right"> 488 - <ShareButton url={shareUrl} title="share track" /> 489 473 </div> 490 474 </div> 491 475 </div> ··· 671 655 max-width: 600px; 672 656 display: flex; 673 657 align-items: flex-start; 674 - gap: 1rem; 675 658 justify-content: center; 676 659 } 677 660 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 661 .track-info { 688 662 flex: 1; 689 663 min-width: 0; ··· 830 804 color: var(--accent-hover); 831 805 } 832 806 833 - .mobile-side-buttons { 834 - display: none; 807 + .side-buttons { 808 + display: flex; 835 809 gap: 0.75rem; 836 810 justify-content: center; 837 811 align-items: center; ··· 916 890 flex-direction: column; 917 891 align-items: center; 918 892 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 893 } 929 894 930 895 .track-info {