feat: inline playlist editing with direct cover upload (#531)

* Add unified edit mode for playlists with track deletion

- Consolidate edit functionality into single edit mode (like Spotify)
- Edit button now toggles edit mode instead of opening modal
- In edit mode: show drag handles for reordering, delete buttons for tracks, and edit details button
- Remove separate reorder button - reordering is now part of edit mode
- Add track deletion UI with loading states and error handling
- Track deletion uses existing backend API endpoint

* fix: simplify playlist edit mode UX

- fix undefined showEdit variable errors (use showEditModal)
- simplify edit mode: one button to enter/exit edit mode
- in edit mode: cover art is clickable to edit metadata, tracks show
drag handles and delete buttons, add tracks button appears inline
- remove redundant "edit details", "add tracks", "saving..." buttons
- use proper button element for clickable cover art (fixes a11y warning)
- clean up unused CSS selectors

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

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

* feat: inline playlist editing with direct cover upload

- title editable inline as input field when in edit mode
- cover art directly replaceable via click (opens file picker)
- removed edit modal entirely - all editing is now inline
- fixed font inheritance on overlay text
- cleaned up unused CSS selectors from removed modal

🤖 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 ca9ddd7e f4934b18

Changed files
+892 -561
frontend
src
routes
playlist
+892 -561
frontend/src/routes/playlist/[id]/+page.svelte
··· 1 <script lang="ts"> 2 - import Header from '$lib/components/Header.svelte'; 3 - import ShareButton from '$lib/components/ShareButton.svelte'; 4 - import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 5 - import TrackItem from '$lib/components/TrackItem.svelte'; 6 - import { auth } from '$lib/auth.svelte'; 7 - import { goto } from '$app/navigation'; 8 - import { page } from '$app/stores'; 9 - import { API_URL } from '$lib/config'; 10 - import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 11 - import { toast } from '$lib/toast.svelte'; 12 - import { player } from '$lib/player.svelte'; 13 - import { queue } from '$lib/queue.svelte'; 14 - import type { PageData } from './$types'; 15 - import type { PlaylistWithTracks, Track } from '$lib/types'; 16 17 let { data }: { data: PageData } = $props(); 18 let playlist = $state<PlaylistWithTracks>(data.playlist); ··· 20 21 // search state 22 let showSearch = $state(false); 23 - let searchQuery = $state(''); 24 let searchResults = $state<any[]>([]); 25 let searching = $state(false); 26 - let searchError = $state(''); 27 28 // UI state 29 let deleting = $state(false); 30 let addingTrack = $state<number | null>(null); 31 let showDeleteConfirm = $state(false); 32 33 - // edit modal state 34 - let showEdit = $state(false); 35 - let editName = $state(''); 36 - let editShowOnProfile = $state(false); 37 - let editImageFile = $state<File | null>(null); 38 - let editImagePreview = $state<string | null>(null); 39 - let saving = $state(false); 40 - let uploadingCover = $state(false); 41 - 42 - // reorder state 43 let isEditMode = $state(false); 44 let isSavingOrder = $state(false); 45 46 // drag state 47 let draggedIndex = $state<number | null>(null); 48 let dragOverIndex = $state<number | null>(null); ··· 55 56 async function handleLogout() { 57 await auth.logout(); 58 - window.location.href = '/'; 59 } 60 61 function playTrack(track: Track) { ··· 84 } 85 86 searching = true; 87 - searchError = ''; 88 89 try { 90 - const response = await fetch(`${API_URL}/search/?q=${encodeURIComponent(searchQuery)}&type=tracks&limit=10`, { 91 - credentials: 'include' 92 - }); 93 94 if (!response.ok) { 95 - throw new Error('search failed'); 96 } 97 98 const data = await response.json(); 99 // filter out tracks already in playlist 100 - const existingUris = new Set(tracks.map(t => t.atproto_record_uri)); 101 - searchResults = data.results.filter((r: any) => r.type === 'track' && !existingUris.has(r.atproto_record_uri)); 102 } catch (e) { 103 - searchError = 'failed to search tracks'; 104 searchResults = []; 105 } finally { 106 searching = false; ··· 113 try { 114 // first fetch full track details to get ATProto URI and CID 115 const trackResponse = await fetch(`${API_URL}/tracks/${track.id}`, { 116 - credentials: 'include' 117 }); 118 119 if (!trackResponse.ok) { 120 - throw new Error('failed to fetch track details'); 121 } 122 123 const trackData = await trackResponse.json(); 124 125 - if (!trackData.atproto_record_uri || !trackData.atproto_record_cid) { 126 - throw new Error('track does not have ATProto record'); 127 } 128 129 // add to playlist 130 - const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 131 - method: 'POST', 132 - credentials: 'include', 133 - headers: { 'Content-Type': 'application/json' }, 134 - body: JSON.stringify({ 135 - track_uri: trackData.atproto_record_uri, 136 - track_cid: trackData.atproto_record_cid 137 - }) 138 - }); 139 140 if (!response.ok) { 141 const data = await response.json(); 142 - throw new Error(data.detail || 'failed to add track'); 143 } 144 145 // add full track to local state ··· 149 playlist.track_count = tracks.length; 150 151 // remove from search results 152 - searchResults = searchResults.filter(r => r.id !== track.id); 153 154 toast.success(`added "${trackData.title}" to playlist`); 155 } catch (e) { 156 - console.error('failed to add track:', e); 157 - toast.error(e instanceof Error ? e.message : 'failed to add track'); 158 } finally { 159 addingTrack = null; 160 } 161 } 162 163 - // reorder functions 164 function toggleEditMode() { 165 if (isEditMode) { 166 - saveOrder(); 167 } 168 isEditMode = !isEditMode; 169 } 170 171 async function saveOrder() { 172 if (!playlist.atproto_record_uri) return; 173 174 // extract rkey from list URI (at://did/collection/rkey) 175 - const rkey = playlist.atproto_record_uri.split('/').pop(); 176 if (!rkey) return; 177 178 // build strongRefs from current track order ··· 180 .filter((t) => t.atproto_record_uri && t.atproto_record_cid) 181 .map((t) => ({ 182 uri: t.atproto_record_uri!, 183 - cid: t.atproto_record_cid! 184 })); 185 186 if (items.length === 0) return; ··· 188 isSavingOrder = true; 189 try { 190 const response = await fetch(`${API_URL}/lists/${rkey}/reorder`, { 191 - method: 'PUT', 192 - headers: { 'Content-Type': 'application/json' }, 193 - credentials: 'include', 194 - body: JSON.stringify({ items }) 195 }); 196 197 if (!response.ok) { 198 - const error = await response.json().catch(() => ({ detail: 'unknown error' })); 199 - throw new Error(error.detail || 'failed to save order'); 200 } 201 202 - toast.success('order saved'); 203 } catch (e) { 204 - toast.error(e instanceof Error ? e.message : 'failed to save order'); 205 } finally { 206 isSavingOrder = false; 207 } ··· 220 function handleDragStart(event: DragEvent, index: number) { 221 draggedIndex = index; 222 if (event.dataTransfer) { 223 - event.dataTransfer.effectAllowed = 'move'; 224 } 225 } 226 ··· 249 touchDragIndex = index; 250 touchStartY = touch.clientY; 251 touchDragElement = event.currentTarget as HTMLElement; 252 - touchDragElement.classList.add('touch-dragging'); 253 } 254 255 function handleTouchMove(event: TouchEvent) { 256 - if (touchDragIndex === null || !touchDragElement || !tracksListElement) return; 257 258 event.preventDefault(); 259 const touch = event.touches[0]; 260 const offset = touch.clientY - touchStartY; 261 touchDragElement.style.transform = `translateY(${offset}px)`; 262 263 - const trackElements = tracksListElement.querySelectorAll('.track-row'); 264 for (let i = 0; i < trackElements.length; i++) { 265 const trackEl = trackElements[i] as HTMLElement; 266 const rect = trackEl.getBoundingClientRect(); 267 const midY = rect.top + rect.height / 2; 268 269 if (touch.clientY < midY && i > 0) { 270 - const targetIndex = parseInt(trackEl.dataset.index || '0'); 271 if (targetIndex !== touchDragIndex) { 272 dragOverIndex = targetIndex; 273 } 274 break; 275 } else if (touch.clientY >= midY) { 276 - const targetIndex = parseInt(trackEl.dataset.index || '0'); 277 if (targetIndex !== touchDragIndex) { 278 dragOverIndex = targetIndex; 279 } ··· 282 } 283 284 function handleTouchEnd() { 285 - if (touchDragIndex !== null && dragOverIndex !== null && touchDragIndex !== dragOverIndex) { 286 moveTrack(touchDragIndex, dragOverIndex); 287 } 288 289 if (touchDragElement) { 290 - touchDragElement.classList.remove('touch-dragging'); 291 - touchDragElement.style.transform = ''; 292 } 293 294 touchDragIndex = null; ··· 300 deleting = true; 301 302 try { 303 - const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}`, { 304 - method: 'DELETE', 305 - credentials: 'include' 306 - }); 307 308 if (!response.ok) { 309 - throw new Error('failed to delete playlist'); 310 } 311 312 - toast.success('playlist deleted'); 313 - goto('/library'); 314 } catch (e) { 315 - console.error('failed to delete playlist:', e); 316 - toast.error(e instanceof Error ? e.message : 'failed to delete playlist'); 317 deleting = false; 318 showDeleteConfirm = false; 319 } 320 } 321 322 - function openEditModal() { 323 - editName = playlist.name; 324 - editShowOnProfile = playlist.show_on_profile; 325 - editImageFile = null; 326 - editImagePreview = null; 327 - showEdit = true; 328 - } 329 - 330 - function handleEditImageSelect(event: Event) { 331 - const input = event.target as HTMLInputElement; 332 - const file = input.files?.[0]; 333 - if (!file) return; 334 - 335 - // validate file type 336 - if (!file.type.startsWith('image/')) { 337 - return; 338 - } 339 - 340 - // validate file size (20MB max) 341 - if (file.size > 20 * 1024 * 1024) { 342 - return; 343 - } 344 - 345 - editImageFile = file; 346 - editImagePreview = URL.createObjectURL(file); 347 - } 348 - 349 - async function savePlaylistChanges() { 350 - saving = true; 351 - 352 - try { 353 - // update name and/or show_on_profile if changed 354 - const nameChanged = editName.trim() && editName.trim() !== playlist.name; 355 - const showOnProfileChanged = editShowOnProfile !== playlist.show_on_profile; 356 - 357 - if (nameChanged || showOnProfileChanged) { 358 - const formData = new FormData(); 359 - if (nameChanged) { 360 - formData.append('name', editName.trim()); 361 - } 362 - if (showOnProfileChanged) { 363 - formData.append('show_on_profile', String(editShowOnProfile)); 364 - } 365 - 366 - const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}`, { 367 - method: 'PATCH', 368 - credentials: 'include', 369 - body: formData 370 - }); 371 - 372 - if (!response.ok) { 373 - throw new Error('failed to update playlist'); 374 - } 375 - 376 - const updated = await response.json(); 377 - playlist.name = updated.name; 378 - playlist.show_on_profile = updated.show_on_profile; 379 - } 380 - 381 - // upload cover if selected 382 - if (editImageFile) { 383 - uploadingCover = true; 384 - const formData = new FormData(); 385 - formData.append('image', editImageFile); 386 - 387 - const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/cover`, { 388 - method: 'POST', 389 - credentials: 'include', 390 - body: formData 391 - }); 392 - 393 - if (!response.ok) { 394 - throw new Error('failed to upload cover'); 395 - } 396 - 397 - const result = await response.json(); 398 - playlist.image_url = result.image_url; 399 - uploadingCover = false; 400 - } 401 - 402 - showEdit = false; 403 - toast.success('playlist updated'); 404 - } catch (e) { 405 - console.error('failed to save playlist:', e); 406 - toast.error(e instanceof Error ? e.message : 'failed to save playlist'); 407 - } finally { 408 - saving = false; 409 - uploadingCover = false; 410 - if (editImagePreview) { 411 - URL.revokeObjectURL(editImagePreview); 412 - editImagePreview = null; 413 - } 414 - } 415 - } 416 417 function handleKeydown(event: KeyboardEvent) { 418 - if (event.key === 'Escape') { 419 if (showSearch) { 420 showSearch = false; 421 - searchQuery = ''; 422 searchResults = []; 423 } 424 if (showDeleteConfirm) { 425 showDeleteConfirm = false; 426 } 427 - if (showEdit) { 428 - showEdit = false; 429 - if (editImagePreview) { 430 - URL.revokeObjectURL(editImagePreview); 431 - editImagePreview = null; 432 - } 433 } 434 } 435 } ··· 455 <title>{playlist.name} • plyr</title> 456 <meta 457 name="description" 458 - content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'} on {APP_NAME}" 459 /> 460 461 <!-- Open Graph / Facebook --> 462 <meta property="og:type" content="music.playlist" /> 463 - <meta property="og:title" content="{playlist.name}" /> 464 <meta 465 property="og:description" 466 - content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}" 467 /> 468 - <meta property="og:url" content="{APP_CANONICAL_URL}/playlist/{playlist.id}" /> 469 <meta property="og:site_name" content={APP_NAME} /> 470 {#if playlist.image_url} 471 <meta property="og:image" content={playlist.image_url} /> 472 {/if} 473 474 <!-- Twitter --> 475 - <meta name="twitter:card" content={playlist.image_url ? "summary_large_image" : "summary"} /> 476 - <meta name="twitter:title" content="{playlist.name}" /> 477 <meta 478 name="twitter:description" 479 - content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}" 480 /> 481 {#if playlist.image_url} 482 <meta name="twitter:image" content={playlist.image_url} /> 483 {/if} 484 </svelte:head> 485 486 - <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={handleLogout} /> 487 488 <div class="container"> 489 <main> 490 - <div class="playlist-hero"> 491 - {#if playlist.image_url} 492 - <SensitiveImage src={playlist.image_url} tooltipPosition="center"> 493 - <img src={playlist.image_url} alt="{playlist.name} artwork" class="playlist-art" /> 494 - </SensitiveImage> 495 {:else} 496 - <div class="playlist-art-placeholder"> 497 - <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 498 - <line x1="8" y1="6" x2="21" y2="6"></line> 499 - <line x1="8" y1="12" x2="21" y2="12"></line> 500 - <line x1="8" y1="18" x2="21" y2="18"></line> 501 - <line x1="3" y1="6" x2="3.01" y2="6"></line> 502 - <line x1="3" y1="12" x2="3.01" y2="12"></line> 503 - <line x1="3" y1="18" x2="3.01" y2="18"></line> 504 - </svg> 505 </div> 506 {/if} 507 <div class="playlist-info-wrapper"> 508 <div class="playlist-info"> 509 <p class="playlist-type">playlist</p> 510 - <h1 class="playlist-title">{playlist.name}</h1> 511 <div class="playlist-meta"> 512 <a href="/u/{playlist.owner_handle}" class="owner-link"> 513 {playlist.owner_handle} 514 </a> 515 <span class="meta-separator">•</span> 516 - <span>{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}</span> 517 </div> 518 </div> 519 520 <div class="side-buttons"> 521 <ShareButton url={$page.url.href} title="share playlist" /> 522 {#if isOwner} 523 - <button class="icon-btn" onclick={openEditModal} aria-label="edit playlist metadata" title="edit playlist metadata"> 524 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 525 - <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 526 - <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 527 - </svg> 528 </button> 529 - <button class="icon-btn danger" onclick={() => showDeleteConfirm = true} aria-label="delete playlist" title="delete playlist"> 530 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 531 <polyline points="3 6 5 6 21 6"></polyline> 532 - <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> 533 <path d="M10 11v6"></path> 534 <path d="M14 11v6"></path> 535 <path d="m9 6 .5-2h5l.5 2"></path> ··· 542 543 <div class="playlist-actions"> 544 <button class="play-button" onclick={playNow}> 545 - <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 546 - <path d="M8 5v14l11-7z"/> 547 </svg> 548 play now 549 </button> 550 <button class="queue-button" onclick={addToQueue}> 551 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 552 <line x1="5" y1="15" x2="5" y2="21"></line> 553 <line x1="2" y1="18" x2="8" y2="18"></line> 554 <line x1="9" y1="6" x2="21" y2="6"></line> ··· 557 </svg> 558 add to queue 559 </button> 560 - {#if isOwner} 561 - <button class="add-tracks-button" onclick={() => showSearch = true}> 562 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 563 - <line x1="12" y1="5" x2="12" y2="19"></line> 564 - <line x1="5" y1="12" x2="19" y2="12"></line> 565 - </svg> 566 - add tracks 567 - </button> 568 - {#if tracks.length > 1} 569 <button 570 - class="reorder-button" 571 class:active={isEditMode} 572 onclick={toggleEditMode} 573 - disabled={isSavingOrder} 574 - title={isEditMode ? 'save order' : 'reorder tracks'} 575 > 576 {#if isEditMode} 577 {#if isSavingOrder} 578 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 579 <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 580 </svg> 581 - saving... 582 {:else} 583 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 584 - <polyline points="20 6 9 17 4 12"></polyline> 585 </svg> 586 - done 587 {/if} 588 - {:else} 589 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 590 - <line x1="3" y1="12" x2="21" y2="12"></line> 591 - <line x1="3" y1="6" x2="21" y2="6"></line> 592 - <line x1="3" y1="18" x2="21" y2="18"></line> 593 - </svg> 594 - reorder 595 - {/if} 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> ··· 621 <div class="tracks-section"> 622 <h2 class="section-heading">tracks</h2> 623 {#if tracks.length === 0} 624 - <div class="empty-state"> 625 - <div class="empty-icon"> 626 - <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 627 - <circle cx="11" cy="11" r="8"></circle> 628 - <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 629 - </svg> 630 - </div> 631 - <p>no tracks yet</p> 632 - <span>search for tracks to add to your playlist</span> 633 - {#if isOwner} 634 - <button class="empty-add-btn" onclick={() => showSearch = true}> 635 - add tracks 636 - </button> 637 - {/if} 638 - </div> 639 - {:else} 640 - <div 641 - class="tracks-list" 642 - class:edit-mode={isEditMode} 643 - bind:this={tracksListElement} 644 - ontouchmove={isEditMode ? handleTouchMove : undefined} 645 - ontouchend={isEditMode ? handleTouchEnd : undefined} 646 - ontouchcancel={isEditMode ? handleTouchEnd : undefined} 647 - > 648 - {#each tracks as track, i (track.id)} 649 - {#if isEditMode} 650 - <div 651 - class="track-row" 652 - class:drag-over={dragOverIndex === i && touchDragIndex !== i} 653 - class:is-dragging={touchDragIndex === i || draggedIndex === i} 654 - data-index={i} 655 - role="listitem" 656 - draggable="true" 657 - ondragstart={(e) => handleDragStart(e, i)} 658 - ondragover={(e) => handleDragOver(e, i)} 659 - ondrop={(e) => handleDrop(e, i)} 660 - ondragend={handleDragEnd} 661 - > 662 <button 663 - class="drag-handle" 664 - ontouchstart={(e) => handleTouchStart(e, i)} 665 - onclick={(e) => e.stopPropagation()} 666 - aria-label="drag to reorder" 667 - title="drag to reorder" 668 > 669 - <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 670 - <circle cx="5" cy="3" r="1.5"></circle> 671 - <circle cx="11" cy="3" r="1.5"></circle> 672 - <circle cx="5" cy="8" r="1.5"></circle> 673 - <circle cx="11" cy="8" r="1.5"></circle> 674 - <circle cx="5" cy="13" r="1.5"></circle> 675 - <circle cx="11" cy="13" r="1.5"></circle> 676 - </svg> 677 </button> 678 - <div class="track-content"> 679 <TrackItem 680 {track} 681 index={i} ··· 686 hideAlbum={true} 687 excludePlaylistId={playlist.id} 688 /> 689 - </div> 690 - </div> 691 - {:else} 692 - <TrackItem 693 - {track} 694 - index={i} 695 - showIndex={true} 696 - isPlaying={player.currentTrack?.id === track.id} 697 - onPlay={playTrack} 698 - isAuthenticated={auth.isAuthenticated} 699 - hideAlbum={true} 700 - excludePlaylistId={playlist.id} 701 - /> 702 - {/if} 703 - {/each} 704 - </div> 705 {/if} 706 </div> 707 </main> ··· 709 710 {#if showSearch} 711 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 712 - <div class="modal-overlay" role="presentation" onclick={() => { showSearch = false; searchQuery = ''; searchResults = []; }}> 713 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 714 - <div class="modal search-modal" role="dialog" aria-modal="true" aria-labelledby="add-tracks-title" tabindex="-1" onclick={(e) => e.stopPropagation()}> 715 <div class="modal-header"> 716 <h3 id="add-tracks-title">add tracks</h3> 717 - <button class="close-btn" aria-label="close" onclick={() => { showSearch = false; searchQuery = ''; searchResults = []; }}> 718 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 719 <line x1="18" y1="6" x2="6" y2="18"></line> 720 <line x1="6" y1="6" x2="18" y2="18"></line> 721 </svg> 722 </button> 723 </div> 724 <div class="search-input-wrapper"> 725 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 726 <circle cx="11" cy="11" r="8"></circle> 727 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 728 </svg> ··· 746 {#each searchResults as result} 747 <div class="search-result-item"> 748 {#if result.image_url} 749 - <img src={result.image_url} alt="" class="result-image" /> 750 {:else} 751 <div class="result-image-placeholder"> 752 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 753 <circle cx="12" cy="12" r="10"></circle> 754 <circle cx="12" cy="12" r="3"></circle> 755 </svg> ··· 757 {/if} 758 <div class="result-info"> 759 <span class="result-title">{result.title}</span> 760 - <span class="result-artist">{result.artist_display_name}</span> 761 </div> 762 <button 763 class="add-result-btn" ··· 767 {#if addingTrack === result.id} 768 <span class="spinner"></span> 769 {:else} 770 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 771 - <line x1="12" y1="5" x2="12" y2="19"></line> 772 - <line x1="5" y1="12" x2="19" y2="12"></line> 773 </svg> 774 {/if} 775 </button> ··· 783 784 {#if showDeleteConfirm} 785 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 786 - <div class="modal-overlay" role="presentation" onclick={() => showDeleteConfirm = false}> 787 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 788 - <div class="modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1" onclick={(e) => e.stopPropagation()}> 789 <div class="modal-header"> 790 <h3 id="delete-confirm-title">delete playlist?</h3> 791 </div> 792 <div class="modal-body"> 793 - <p>are you sure you want to delete "{playlist.name}"? this action cannot be undone.</p> 794 - </div> 795 - <div class="modal-footer"> 796 - <button class="cancel-btn" onclick={() => showDeleteConfirm = false} disabled={deleting}> 797 - cancel 798 - </button> 799 - <button class="confirm-btn danger" onclick={deletePlaylist} disabled={deleting}> 800 - {deleting ? 'deleting...' : 'delete'} 801 - </button> 802 - </div> 803 - </div> 804 - </div> 805 - {/if} 806 - 807 - {#if showEdit} 808 - <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 809 - <div class="modal-overlay" role="presentation" onclick={() => { showEdit = false; if (editImagePreview) { URL.revokeObjectURL(editImagePreview); editImagePreview = null; } }}> 810 - <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 811 - <div class="modal edit-modal" role="dialog" aria-modal="true" aria-labelledby="edit-playlist-title" tabindex="-1" onclick={(e) => e.stopPropagation()}> 812 - <div class="modal-header"> 813 - <h3 id="edit-playlist-title">edit playlist</h3> 814 - <button class="close-btn" aria-label="close" onclick={() => { showEdit = false; if (editImagePreview) { URL.revokeObjectURL(editImagePreview); editImagePreview = null; } }}> 815 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 816 - <line x1="18" y1="6" x2="6" y2="18"></line> 817 - <line x1="6" y1="6" x2="18" y2="18"></line> 818 - </svg> 819 - </button> 820 - </div> 821 - <div class="modal-body"> 822 - <div class="edit-cover-section"> 823 - <label class="cover-picker"> 824 - {#if editImagePreview} 825 - <img src={editImagePreview} alt="preview" class="cover-preview" /> 826 - {:else if playlist.image_url} 827 - <SensitiveImage src={playlist.image_url} tooltipPosition="center"> 828 - <img src={playlist.image_url} alt="current cover" class="cover-preview" /> 829 - </SensitiveImage> 830 - {:else} 831 - <div class="cover-placeholder"> 832 - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 833 - <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 834 - <circle cx="8.5" cy="8.5" r="1.5"></circle> 835 - <polyline points="21 15 16 10 5 21"></polyline> 836 - </svg> 837 - <span>add cover</span> 838 - </div> 839 - {/if} 840 - <input type="file" accept="image/jpeg,image/png,image/webp" onchange={handleEditImageSelect} hidden /> 841 - </label> 842 - <span class="cover-hint">click to change cover art</span> 843 - </div> 844 - <div class="edit-name-section"> 845 - <label for="edit-name">playlist name</label> 846 - <input 847 - id="edit-name" 848 - type="text" 849 - bind:value={editName} 850 - placeholder="playlist name" 851 - /> 852 - </div> 853 - <div class="edit-toggle-section"> 854 - <label class="toggle-row"> 855 - <input 856 - type="checkbox" 857 - bind:checked={editShowOnProfile} 858 - /> 859 - <span class="toggle-label">show on profile</span> 860 - </label> 861 - <span class="toggle-hint">when enabled, this playlist will appear in your public collections</span> 862 - </div> 863 </div> 864 <div class="modal-footer"> 865 - <button class="cancel-btn" onclick={() => { showEdit = false; if (editImagePreview) { URL.revokeObjectURL(editImagePreview); editImagePreview = null; } }} disabled={saving}> 866 cancel 867 </button> 868 - <button class="confirm-btn" onclick={savePlaylistChanges} disabled={saving || (!editImageFile && editName.trim() === playlist.name && editShowOnProfile === playlist.show_on_profile)}> 869 - {#if saving} 870 - {uploadingCover ? 'uploading cover...' : 'saving...'} 871 - {:else} 872 - save 873 - {/if} 874 </button> 875 </div> 876 </div> ··· 881 .container { 882 max-width: 1200px; 883 margin: 0 auto; 884 - padding: 0 1rem calc(var(--player-height, 120px) + 2rem + env(safe-area-inset-bottom, 0px)) 1rem; 885 } 886 887 main { ··· 915 color: var(--text-muted); 916 } 917 918 .playlist-info-wrapper { 919 flex: 1; 920 display: flex; ··· 961 hyphens: auto; 962 } 963 964 .playlist-meta { 965 display: flex; 966 align-items: center; ··· 1009 color: #ef4444; 1010 } 1011 1012 /* playlist actions */ 1013 .playlist-actions { 1014 display: flex; ··· 1017 } 1018 1019 .play-button, 1020 - .queue-button, 1021 - .add-tracks-button, 1022 - .reorder-button { 1023 padding: 0.75rem 1.5rem; 1024 border-radius: 24px; 1025 font-weight: 600; ··· 1042 transform: scale(1.05); 1043 } 1044 1045 - .queue-button, 1046 - .add-tracks-button, 1047 - .reorder-button { 1048 background: transparent; 1049 color: var(--text-primary); 1050 border: 1px solid var(--border-default); 1051 } 1052 1053 - .queue-button:hover, 1054 - .add-tracks-button:hover, 1055 - .reorder-button:hover { 1056 - border-color: var(--accent); 1057 - color: var(--accent); 1058 - } 1059 - 1060 - .reorder-button:disabled { 1061 - opacity: 0.6; 1062 - cursor: not-allowed; 1063 - } 1064 - 1065 - .reorder-button.active { 1066 border-color: var(--accent); 1067 color: var(--accent); 1068 - background: color-mix(in srgb, var(--accent) 10%, transparent); 1069 } 1070 1071 .spinner { ··· 1084 /* tracks section */ 1085 .tracks-section { 1086 margin-top: 2rem; 1087 - padding-bottom: calc(var(--player-height, 120px) + env(safe-area-inset-bottom, 0px)); 1088 } 1089 1090 .section-heading { ··· 1165 min-width: 0; 1166 } 1167 1168 /* empty state */ 1169 .empty-state { 1170 display: flex; ··· 1482 } 1483 } 1484 1485 - /* edit modal */ 1486 - .edit-modal { 1487 - max-width: 400px; 1488 - } 1489 - 1490 - .edit-cover-section { 1491 - display: flex; 1492 - flex-direction: column; 1493 - align-items: center; 1494 - gap: 0.5rem; 1495 - margin-bottom: 1.5rem; 1496 - } 1497 - 1498 - .cover-picker { 1499 - width: 120px; 1500 - height: 120px; 1501 - border-radius: 12px; 1502 - overflow: hidden; 1503 - cursor: pointer; 1504 - border: 2px dashed var(--border-default); 1505 - transition: border-color 0.15s; 1506 - } 1507 - 1508 - .cover-picker:hover { 1509 - border-color: var(--accent); 1510 - } 1511 - 1512 - .cover-preview { 1513 - width: 100%; 1514 - height: 100%; 1515 - object-fit: cover; 1516 - } 1517 - 1518 - .cover-placeholder { 1519 - width: 100%; 1520 - height: 100%; 1521 - display: flex; 1522 - flex-direction: column; 1523 - align-items: center; 1524 - justify-content: center; 1525 - gap: 0.5rem; 1526 - background: var(--bg-secondary); 1527 - color: var(--text-muted); 1528 - } 1529 - 1530 - .cover-placeholder span { 1531 - font-size: 0.8rem; 1532 - } 1533 - 1534 - .cover-hint { 1535 - font-size: 0.75rem; 1536 - color: var(--text-muted); 1537 - } 1538 - 1539 - .edit-name-section { 1540 - display: flex; 1541 - flex-direction: column; 1542 - gap: 0.5rem; 1543 - } 1544 - 1545 - .edit-name-section label { 1546 - font-size: 0.85rem; 1547 - color: var(--text-secondary); 1548 - } 1549 - 1550 - .edit-name-section input { 1551 - width: 100%; 1552 - padding: 0.75rem 1rem; 1553 - background: var(--bg-secondary); 1554 - border: 1px solid var(--border-default); 1555 - border-radius: 8px; 1556 - font-family: inherit; 1557 - font-size: 1rem; 1558 - color: var(--text-primary); 1559 - outline: none; 1560 - transition: border-color 0.15s; 1561 - box-sizing: border-box; 1562 - } 1563 - 1564 - .edit-name-section input:focus { 1565 - border-color: var(--accent); 1566 - } 1567 - 1568 - .edit-name-section input::placeholder { 1569 - color: var(--text-muted); 1570 - } 1571 - 1572 - .edit-toggle-section { 1573 - display: flex; 1574 - flex-direction: column; 1575 - gap: 0.5rem; 1576 - margin-top: 0.5rem; 1577 - } 1578 - 1579 - .toggle-row { 1580 - display: flex; 1581 - align-items: center; 1582 - gap: 0.75rem; 1583 - cursor: pointer; 1584 - } 1585 - 1586 - .toggle-row input[type="checkbox"] { 1587 - width: 18px; 1588 - height: 18px; 1589 - accent-color: var(--accent); 1590 - cursor: pointer; 1591 - } 1592 - 1593 - .toggle-label { 1594 - font-size: 0.95rem; 1595 - color: var(--text-primary); 1596 - } 1597 - 1598 - .toggle-hint { 1599 - font-size: 0.75rem; 1600 - color: var(--text-muted); 1601 - padding-left: calc(18px + 0.75rem); 1602 - } 1603 - 1604 @media (max-width: 768px) { 1605 .playlist-hero { 1606 flex-direction: column; ··· 1631 align-items: center; 1632 } 1633 1634 - .playlist-title { 1635 font-size: 2rem; 1636 } 1637 ··· 1646 } 1647 1648 .play-button, 1649 - .queue-button, 1650 - .add-tracks-button, 1651 - .reorder-button { 1652 width: 100%; 1653 justify-content: center; 1654 } 1655 } 1656 ··· 1665 height: 140px; 1666 } 1667 1668 - .playlist-title { 1669 font-size: 1.75rem; 1670 } 1671 ··· 1674 flex-wrap: wrap; 1675 } 1676 } 1677 - 1678 </style>
··· 1 <script lang="ts"> 2 + import Header from "$lib/components/Header.svelte"; 3 + import ShareButton from "$lib/components/ShareButton.svelte"; 4 + import SensitiveImage from "$lib/components/SensitiveImage.svelte"; 5 + import TrackItem from "$lib/components/TrackItem.svelte"; 6 + import { auth } from "$lib/auth.svelte"; 7 + import { goto } from "$app/navigation"; 8 + import { page } from "$app/stores"; 9 + import { API_URL } from "$lib/config"; 10 + import { APP_NAME, APP_CANONICAL_URL } from "$lib/branding"; 11 + import { toast } from "$lib/toast.svelte"; 12 + import { player } from "$lib/player.svelte"; 13 + import { queue } from "$lib/queue.svelte"; 14 + import type { PageData } from "./$types"; 15 + import type { PlaylistWithTracks, Track } from "$lib/types"; 16 17 let { data }: { data: PageData } = $props(); 18 let playlist = $state<PlaylistWithTracks>(data.playlist); ··· 20 21 // search state 22 let showSearch = $state(false); 23 + let searchQuery = $state(""); 24 let searchResults = $state<any[]>([]); 25 let searching = $state(false); 26 + let searchError = $state(""); 27 28 // UI state 29 let deleting = $state(false); 30 let addingTrack = $state<number | null>(null); 31 let showDeleteConfirm = $state(false); 32 + let removingTrackId = $state<number | null>(null); 33 34 + // unified edit mode state 35 let isEditMode = $state(false); 36 let isSavingOrder = $state(false); 37 38 + // inline edit state 39 + let editName = $state(""); 40 + let coverInputElement = $state<HTMLInputElement | null>(null); 41 + let uploadingCover = $state(false); 42 + 43 // drag state 44 let draggedIndex = $state<number | null>(null); 45 let dragOverIndex = $state<number | null>(null); ··· 52 53 async function handleLogout() { 54 await auth.logout(); 55 + window.location.href = "/"; 56 } 57 58 function playTrack(track: Track) { ··· 81 } 82 83 searching = true; 84 + searchError = ""; 85 86 try { 87 + const response = await fetch( 88 + `${API_URL}/search/?q=${encodeURIComponent(searchQuery)}&type=tracks&limit=10`, 89 + { 90 + credentials: "include", 91 + }, 92 + ); 93 94 if (!response.ok) { 95 + throw new Error("search failed"); 96 } 97 98 const data = await response.json(); 99 // filter out tracks already in playlist 100 + const existingUris = new Set( 101 + tracks.map((t) => t.atproto_record_uri), 102 + ); 103 + searchResults = data.results.filter( 104 + (r: any) => 105 + r.type === "track" && 106 + !existingUris.has(r.atproto_record_uri), 107 + ); 108 } catch (e) { 109 + searchError = "failed to search tracks"; 110 searchResults = []; 111 } finally { 112 searching = false; ··· 119 try { 120 // first fetch full track details to get ATProto URI and CID 121 const trackResponse = await fetch(`${API_URL}/tracks/${track.id}`, { 122 + credentials: "include", 123 }); 124 125 if (!trackResponse.ok) { 126 + throw new Error("failed to fetch track details"); 127 } 128 129 const trackData = await trackResponse.json(); 130 131 + if ( 132 + !trackData.atproto_record_uri || 133 + !trackData.atproto_record_cid 134 + ) { 135 + throw new Error("track does not have ATProto record"); 136 } 137 138 // add to playlist 139 + const response = await fetch( 140 + `${API_URL}/lists/playlists/${playlist.id}/tracks`, 141 + { 142 + method: "POST", 143 + credentials: "include", 144 + headers: { "Content-Type": "application/json" }, 145 + body: JSON.stringify({ 146 + track_uri: trackData.atproto_record_uri, 147 + track_cid: trackData.atproto_record_cid, 148 + }), 149 + }, 150 + ); 151 152 if (!response.ok) { 153 const data = await response.json(); 154 + throw new Error(data.detail || "failed to add track"); 155 } 156 157 // add full track to local state ··· 161 playlist.track_count = tracks.length; 162 163 // remove from search results 164 + searchResults = searchResults.filter((r) => r.id !== track.id); 165 166 toast.success(`added "${trackData.title}" to playlist`); 167 } catch (e) { 168 + console.error("failed to add track:", e); 169 + toast.error(e instanceof Error ? e.message : "failed to add track"); 170 } finally { 171 addingTrack = null; 172 } 173 } 174 175 + async function removeTrack(track: Track) { 176 + if (!track.atproto_record_uri) { 177 + toast.error("track does not have ATProto record"); 178 + return; 179 + } 180 + 181 + removingTrackId = track.id; 182 + 183 + try { 184 + const response = await fetch( 185 + `${API_URL}/lists/playlists/${playlist.id}/tracks/${encodeURIComponent(track.atproto_record_uri)}`, 186 + { 187 + method: "DELETE", 188 + credentials: "include", 189 + }, 190 + ); 191 + 192 + if (!response.ok) { 193 + const data = await response.json(); 194 + throw new Error(data.detail || "failed to remove track"); 195 + } 196 + 197 + tracks = tracks.filter((t) => t.id !== track.id); 198 + playlist.track_count = tracks.length; 199 + 200 + toast.success(`removed "${track.title}" from playlist`); 201 + } catch (e) { 202 + console.error("failed to remove track:", e); 203 + toast.error( 204 + e instanceof Error ? e.message : "failed to remove track", 205 + ); 206 + } finally { 207 + removingTrackId = null; 208 + } 209 + } 210 + 211 function toggleEditMode() { 212 if (isEditMode) { 213 + // exiting edit mode - save changes 214 + saveAllChanges(); 215 + } else { 216 + // entering edit mode - initialize edit state 217 + editName = playlist.name; 218 } 219 isEditMode = !isEditMode; 220 } 221 222 + async function saveAllChanges() { 223 + // save track order 224 + await saveOrder(); 225 + 226 + // save name if changed 227 + if (editName.trim() && editName.trim() !== playlist.name) { 228 + await saveNameChange(); 229 + } 230 + } 231 + 232 + async function saveNameChange() { 233 + if (!editName.trim() || editName.trim() === playlist.name) return; 234 + 235 + try { 236 + const formData = new FormData(); 237 + formData.append("name", editName.trim()); 238 + 239 + const response = await fetch( 240 + `${API_URL}/lists/playlists/${playlist.id}`, 241 + { 242 + method: "PATCH", 243 + credentials: "include", 244 + body: formData, 245 + }, 246 + ); 247 + 248 + if (!response.ok) { 249 + throw new Error("failed to update name"); 250 + } 251 + 252 + const updated = await response.json(); 253 + playlist.name = updated.name; 254 + } catch (e) { 255 + console.error("failed to save name:", e); 256 + toast.error(e instanceof Error ? e.message : "failed to save name"); 257 + // revert to original name 258 + editName = playlist.name; 259 + } 260 + } 261 + 262 + function handleCoverSelect(event: Event) { 263 + const input = event.target as HTMLInputElement; 264 + const file = input.files?.[0]; 265 + if (!file) return; 266 + 267 + if (!file.type.startsWith("image/")) { 268 + toast.error("please select an image file"); 269 + return; 270 + } 271 + 272 + if (file.size > 20 * 1024 * 1024) { 273 + toast.error("image must be under 20MB"); 274 + return; 275 + } 276 + 277 + uploadCover(file); 278 + } 279 + 280 + async function uploadCover(file: File) { 281 + uploadingCover = true; 282 + try { 283 + const formData = new FormData(); 284 + formData.append("image", file); 285 + 286 + const response = await fetch( 287 + `${API_URL}/lists/playlists/${playlist.id}/cover`, 288 + { 289 + method: "POST", 290 + credentials: "include", 291 + body: formData, 292 + }, 293 + ); 294 + 295 + if (!response.ok) { 296 + throw new Error("failed to upload cover"); 297 + } 298 + 299 + const result = await response.json(); 300 + playlist.image_url = result.image_url; 301 + toast.success("cover updated"); 302 + } catch (e) { 303 + console.error("failed to upload cover:", e); 304 + toast.error(e instanceof Error ? e.message : "failed to upload cover"); 305 + } finally { 306 + uploadingCover = false; 307 + } 308 + } 309 + 310 async function saveOrder() { 311 if (!playlist.atproto_record_uri) return; 312 313 // extract rkey from list URI (at://did/collection/rkey) 314 + const rkey = playlist.atproto_record_uri.split("/").pop(); 315 if (!rkey) return; 316 317 // build strongRefs from current track order ··· 319 .filter((t) => t.atproto_record_uri && t.atproto_record_cid) 320 .map((t) => ({ 321 uri: t.atproto_record_uri!, 322 + cid: t.atproto_record_cid!, 323 })); 324 325 if (items.length === 0) return; ··· 327 isSavingOrder = true; 328 try { 329 const response = await fetch(`${API_URL}/lists/${rkey}/reorder`, { 330 + method: "PUT", 331 + headers: { "Content-Type": "application/json" }, 332 + credentials: "include", 333 + body: JSON.stringify({ items }), 334 }); 335 336 if (!response.ok) { 337 + const error = await response 338 + .json() 339 + .catch(() => ({ detail: "unknown error" })); 340 + throw new Error(error.detail || "failed to save order"); 341 } 342 343 + toast.success("order saved"); 344 } catch (e) { 345 + toast.error( 346 + e instanceof Error ? e.message : "failed to save order", 347 + ); 348 } finally { 349 isSavingOrder = false; 350 } ··· 363 function handleDragStart(event: DragEvent, index: number) { 364 draggedIndex = index; 365 if (event.dataTransfer) { 366 + event.dataTransfer.effectAllowed = "move"; 367 } 368 } 369 ··· 392 touchDragIndex = index; 393 touchStartY = touch.clientY; 394 touchDragElement = event.currentTarget as HTMLElement; 395 + touchDragElement.classList.add("touch-dragging"); 396 } 397 398 function handleTouchMove(event: TouchEvent) { 399 + if (touchDragIndex === null || !touchDragElement || !tracksListElement) 400 + return; 401 402 event.preventDefault(); 403 const touch = event.touches[0]; 404 const offset = touch.clientY - touchStartY; 405 touchDragElement.style.transform = `translateY(${offset}px)`; 406 407 + const trackElements = tracksListElement.querySelectorAll(".track-row"); 408 for (let i = 0; i < trackElements.length; i++) { 409 const trackEl = trackElements[i] as HTMLElement; 410 const rect = trackEl.getBoundingClientRect(); 411 const midY = rect.top + rect.height / 2; 412 413 if (touch.clientY < midY && i > 0) { 414 + const targetIndex = parseInt(trackEl.dataset.index || "0"); 415 if (targetIndex !== touchDragIndex) { 416 dragOverIndex = targetIndex; 417 } 418 break; 419 } else if (touch.clientY >= midY) { 420 + const targetIndex = parseInt(trackEl.dataset.index || "0"); 421 if (targetIndex !== touchDragIndex) { 422 dragOverIndex = targetIndex; 423 } ··· 426 } 427 428 function handleTouchEnd() { 429 + if ( 430 + touchDragIndex !== null && 431 + dragOverIndex !== null && 432 + touchDragIndex !== dragOverIndex 433 + ) { 434 moveTrack(touchDragIndex, dragOverIndex); 435 } 436 437 if (touchDragElement) { 438 + touchDragElement.classList.remove("touch-dragging"); 439 + touchDragElement.style.transform = ""; 440 } 441 442 touchDragIndex = null; ··· 448 deleting = true; 449 450 try { 451 + const response = await fetch( 452 + `${API_URL}/lists/playlists/${playlist.id}`, 453 + { 454 + method: "DELETE", 455 + credentials: "include", 456 + }, 457 + ); 458 459 if (!response.ok) { 460 + throw new Error("failed to delete playlist"); 461 } 462 463 + toast.success("playlist deleted"); 464 + goto("/library"); 465 } catch (e) { 466 + console.error("failed to delete playlist:", e); 467 + toast.error( 468 + e instanceof Error ? e.message : "failed to delete playlist", 469 + ); 470 deleting = false; 471 showDeleteConfirm = false; 472 } 473 } 474 475 476 function handleKeydown(event: KeyboardEvent) { 477 + if (event.key === "Escape") { 478 if (showSearch) { 479 showSearch = false; 480 + searchQuery = ""; 481 searchResults = []; 482 } 483 if (showDeleteConfirm) { 484 showDeleteConfirm = false; 485 } 486 + if (isEditMode) { 487 + // revert name change and exit edit mode 488 + editName = playlist.name; 489 + isEditMode = false; 490 } 491 } 492 } ··· 512 <title>{playlist.name} • plyr</title> 513 <meta 514 name="description" 515 + content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 516 + 1 517 + ? 'track' 518 + : 'tracks'} on {APP_NAME}" 519 /> 520 521 <!-- Open Graph / Facebook --> 522 <meta property="og:type" content="music.playlist" /> 523 + <meta property="og:title" content={playlist.name} /> 524 <meta 525 property="og:description" 526 + content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 527 + 1 528 + ? 'track' 529 + : 'tracks'}" 530 + /> 531 + <meta 532 + property="og:url" 533 + content="{APP_CANONICAL_URL}/playlist/{playlist.id}" 534 /> 535 <meta property="og:site_name" content={APP_NAME} /> 536 {#if playlist.image_url} 537 <meta property="og:image" content={playlist.image_url} /> 538 {/if} 539 540 <!-- Twitter --> 541 + <meta 542 + name="twitter:card" 543 + content={playlist.image_url ? "summary_large_image" : "summary"} 544 + /> 545 + <meta name="twitter:title" content={playlist.name} /> 546 <meta 547 name="twitter:description" 548 + content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 549 + 1 550 + ? 'track' 551 + : 'tracks'}" 552 /> 553 {#if playlist.image_url} 554 <meta name="twitter:image" content={playlist.image_url} /> 555 {/if} 556 </svelte:head> 557 558 + <Header 559 + user={auth.user} 560 + isAuthenticated={auth.isAuthenticated} 561 + onLogout={handleLogout} 562 + /> 563 564 <div class="container"> 565 <main> 566 + <div class="playlist-hero" class:edit-mode={isEditMode && isOwner}> 567 + <!-- hidden file input for cover upload --> 568 + <input 569 + type="file" 570 + accept="image/jpeg,image/png,image/webp" 571 + bind:this={coverInputElement} 572 + onchange={handleCoverSelect} 573 + hidden 574 + /> 575 + {#if isEditMode && isOwner} 576 + <button 577 + class="playlist-art-wrapper clickable" 578 + onclick={() => coverInputElement?.click()} 579 + type="button" 580 + aria-label="change cover image" 581 + disabled={uploadingCover} 582 + > 583 + {#if playlist.image_url} 584 + <img 585 + src={playlist.image_url} 586 + alt="{playlist.name} artwork" 587 + class="playlist-art" 588 + /> 589 + {:else} 590 + <div class="playlist-art-placeholder"> 591 + <svg 592 + width="64" 593 + height="64" 594 + viewBox="0 0 24 24" 595 + fill="none" 596 + stroke="currentColor" 597 + stroke-width="1.5" 598 + > 599 + <line x1="8" y1="6" x2="21" y2="6"></line> 600 + <line x1="8" y1="12" x2="21" y2="12"></line> 601 + <line x1="8" y1="18" x2="21" y2="18"></line> 602 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 603 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 604 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 605 + </svg> 606 + </div> 607 + {/if} 608 + <div class="art-edit-overlay" class:uploading={uploadingCover}> 609 + {#if uploadingCover} 610 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 611 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 612 + </svg> 613 + <span>uploading...</span> 614 + {:else} 615 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 616 + <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 617 + <circle cx="8.5" cy="8.5" r="1.5"></circle> 618 + <polyline points="21 15 16 10 5 21"></polyline> 619 + </svg> 620 + <span>change cover</span> 621 + {/if} 622 + </div> 623 + </button> 624 {:else} 625 + <div class="playlist-art-wrapper"> 626 + {#if playlist.image_url} 627 + <SensitiveImage 628 + src={playlist.image_url} 629 + tooltipPosition="center" 630 + > 631 + <img 632 + src={playlist.image_url} 633 + alt="{playlist.name} artwork" 634 + class="playlist-art" 635 + /> 636 + </SensitiveImage> 637 + {:else} 638 + <div class="playlist-art-placeholder"> 639 + <svg 640 + width="64" 641 + height="64" 642 + viewBox="0 0 24 24" 643 + fill="none" 644 + stroke="currentColor" 645 + stroke-width="1.5" 646 + > 647 + <line x1="8" y1="6" x2="21" y2="6"></line> 648 + <line x1="8" y1="12" x2="21" y2="12"></line> 649 + <line x1="8" y1="18" x2="21" y2="18"></line> 650 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 651 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 652 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 653 + </svg> 654 + </div> 655 + {/if} 656 </div> 657 {/if} 658 <div class="playlist-info-wrapper"> 659 <div class="playlist-info"> 660 <p class="playlist-type">playlist</p> 661 + {#if isEditMode && isOwner} 662 + <input 663 + type="text" 664 + class="playlist-title-input" 665 + bind:value={editName} 666 + placeholder="playlist name" 667 + /> 668 + {:else} 669 + <h1 class="playlist-title">{playlist.name}</h1> 670 + {/if} 671 <div class="playlist-meta"> 672 <a href="/u/{playlist.owner_handle}" class="owner-link"> 673 {playlist.owner_handle} 674 </a> 675 <span class="meta-separator">•</span> 676 + <span 677 + >{playlist.track_count} 678 + {playlist.track_count === 1 679 + ? "track" 680 + : "tracks"}</span 681 + > 682 </div> 683 </div> 684 685 <div class="side-buttons"> 686 <ShareButton url={$page.url.href} title="share playlist" /> 687 {#if isOwner} 688 + <button 689 + class="icon-btn" 690 + class:active={isEditMode} 691 + onclick={toggleEditMode} 692 + aria-label={isEditMode ? "done editing" : "edit playlist"} 693 + title={isEditMode ? "done editing" : "edit playlist"} 694 + > 695 + {#if isEditMode} 696 + {#if isSavingOrder} 697 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 698 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 699 + </svg> 700 + {:else} 701 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 702 + <polyline points="20 6 9 17 4 12"></polyline> 703 + </svg> 704 + {/if} 705 + {:else} 706 + <svg 707 + width="18" 708 + height="18" 709 + viewBox="0 0 24 24" 710 + fill="none" 711 + stroke="currentColor" 712 + stroke-width="2" 713 + stroke-linecap="round" 714 + stroke-linejoin="round" 715 + > 716 + <path 717 + d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" 718 + ></path> 719 + <path 720 + d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" 721 + ></path> 722 + </svg> 723 + {/if} 724 </button> 725 + <button 726 + class="icon-btn danger" 727 + onclick={() => (showDeleteConfirm = true)} 728 + aria-label="delete playlist" 729 + title="delete playlist" 730 + > 731 + <svg 732 + width="18" 733 + height="18" 734 + viewBox="0 0 24 24" 735 + fill="none" 736 + stroke="currentColor" 737 + stroke-width="2" 738 + stroke-linecap="round" 739 + stroke-linejoin="round" 740 + > 741 <polyline points="3 6 5 6 21 6"></polyline> 742 + <path 743 + d="m19 6-.867 12.142A2 2 0 0 1 16.138 20H7.862a2 2 0 0 1-1.995-1.858L5 6" 744 + ></path> 745 <path d="M10 11v6"></path> 746 <path d="M14 11v6"></path> 747 <path d="m9 6 .5-2h5l.5 2"></path> ··· 754 755 <div class="playlist-actions"> 756 <button class="play-button" onclick={playNow}> 757 + <svg 758 + width="20" 759 + height="20" 760 + viewBox="0 0 24 24" 761 + fill="currentColor" 762 + > 763 + <path d="M8 5v14l11-7z" /> 764 </svg> 765 play now 766 </button> 767 <button class="queue-button" onclick={addToQueue}> 768 + <svg 769 + width="18" 770 + height="18" 771 + viewBox="0 0 24 24" 772 + fill="none" 773 + stroke="currentColor" 774 + stroke-width="2" 775 + stroke-linecap="round" 776 + > 777 <line x1="5" y1="15" x2="5" y2="21"></line> 778 <line x1="2" y1="18" x2="8" y2="18"></line> 779 <line x1="9" y1="6" x2="21" y2="6"></line> ··· 782 </svg> 783 add to queue 784 </button> 785 + <div class="mobile-buttons"> 786 + <ShareButton url={$page.url.href} title="share playlist" /> 787 + {#if isOwner} 788 <button 789 + class="icon-btn" 790 class:active={isEditMode} 791 onclick={toggleEditMode} 792 + aria-label={isEditMode ? "done editing" : "edit playlist"} 793 + title={isEditMode ? "done editing" : "edit playlist"} 794 > 795 {#if isEditMode} 796 {#if isSavingOrder} 797 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 798 <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 799 </svg> 800 + {:else} 801 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 802 + <polyline points="20 6 9 17 4 12"></polyline> 803 + </svg> 804 + {/if} 805 {:else} 806 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 807 + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 808 + <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 809 </svg> 810 {/if} 811 </button> 812 + <button 813 + class="icon-btn danger" 814 + onclick={() => (showDeleteConfirm = true)} 815 + aria-label="delete playlist" 816 + title="delete playlist" 817 + > 818 + <svg 819 + width="18" 820 + height="18" 821 + viewBox="0 0 24 24" 822 + fill="none" 823 + stroke="currentColor" 824 + stroke-width="2" 825 + stroke-linecap="round" 826 + stroke-linejoin="round" 827 + > 828 <polyline points="3 6 5 6 21 6"></polyline> 829 + <path 830 + d="m19 6-.867 12.142A2 2 0 0 1 16.138 20H7.862a2 2 0 0 1-1.995-1.858L5 6" 831 + ></path> 832 <path d="M10 11v6"></path> 833 <path d="M14 11v6"></path> 834 <path d="m9 6 .5-2h5l.5 2"></path> ··· 841 <div class="tracks-section"> 842 <h2 class="section-heading">tracks</h2> 843 {#if tracks.length === 0} 844 + <div class="empty-state"> 845 + <div class="empty-icon"> 846 + <svg 847 + width="32" 848 + height="32" 849 + viewBox="0 0 24 24" 850 + fill="none" 851 + stroke="currentColor" 852 + stroke-width="1.5" 853 + stroke-linecap="round" 854 + stroke-linejoin="round" 855 + > 856 + <circle cx="11" cy="11" r="8"></circle> 857 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 858 + </svg> 859 + </div> 860 + <p>no tracks yet</p> 861 + <span>search for tracks to add to your playlist</span> 862 + {#if isOwner} 863 <button 864 + class="empty-add-btn" 865 + onclick={() => (showSearch = true)} 866 > 867 + add tracks 868 </button> 869 + {/if} 870 + </div> 871 + {:else} 872 + <div 873 + class="tracks-list" 874 + class:edit-mode={isEditMode} 875 + bind:this={tracksListElement} 876 + ontouchmove={isEditMode ? handleTouchMove : undefined} 877 + ontouchend={isEditMode ? handleTouchEnd : undefined} 878 + ontouchcancel={isEditMode ? handleTouchEnd : undefined} 879 + > 880 + {#each tracks as track, i (track.id)} 881 + {#if isEditMode} 882 + <div 883 + class="track-row" 884 + class:drag-over={dragOverIndex === i && 885 + touchDragIndex !== i} 886 + class:is-dragging={touchDragIndex === i || 887 + draggedIndex === i} 888 + data-index={i} 889 + role="listitem" 890 + draggable="true" 891 + ondragstart={(e) => handleDragStart(e, i)} 892 + ondragover={(e) => handleDragOver(e, i)} 893 + ondrop={(e) => handleDrop(e, i)} 894 + ondragend={handleDragEnd} 895 + > 896 + <button 897 + class="drag-handle" 898 + ontouchstart={(e) => handleTouchStart(e, i)} 899 + onclick={(e) => e.stopPropagation()} 900 + aria-label="drag to reorder" 901 + title="drag to reorder" 902 + > 903 + <svg 904 + width="16" 905 + height="16" 906 + viewBox="0 0 16 16" 907 + fill="currentColor" 908 + > 909 + <circle cx="5" cy="3" r="1.5"></circle> 910 + <circle cx="11" cy="3" r="1.5"></circle> 911 + <circle cx="5" cy="8" r="1.5"></circle> 912 + <circle cx="11" cy="8" r="1.5"></circle> 913 + <circle cx="5" cy="13" r="1.5"></circle> 914 + <circle cx="11" cy="13" r="1.5" 915 + ></circle> 916 + </svg> 917 + </button> 918 + <div class="track-content"> 919 + <TrackItem 920 + {track} 921 + index={i} 922 + showIndex={true} 923 + isPlaying={player.currentTrack?.id === 924 + track.id} 925 + onPlay={playTrack} 926 + isAuthenticated={auth.isAuthenticated} 927 + hideAlbum={true} 928 + excludePlaylistId={playlist.id} 929 + /> 930 + </div> 931 + <button 932 + class="remove-track-btn" 933 + onclick={(e) => { 934 + e.stopPropagation(); 935 + removeTrack(track); 936 + }} 937 + disabled={removingTrackId === track.id} 938 + aria-label="remove track from playlist" 939 + title="remove track" 940 + > 941 + {#if removingTrackId === track.id} 942 + <svg 943 + width="16" 944 + height="16" 945 + viewBox="0 0 24 24" 946 + fill="none" 947 + stroke="currentColor" 948 + stroke-width="2" 949 + stroke-linecap="round" 950 + stroke-linejoin="round" 951 + class="spinner" 952 + > 953 + <circle 954 + cx="12" 955 + cy="12" 956 + r="10" 957 + stroke-dasharray="31.4" 958 + stroke-dashoffset="10" 959 + ></circle> 960 + </svg> 961 + {:else} 962 + <svg 963 + width="16" 964 + height="16" 965 + viewBox="0 0 24 24" 966 + fill="none" 967 + stroke="currentColor" 968 + stroke-width="2" 969 + stroke-linecap="round" 970 + stroke-linejoin="round" 971 + > 972 + <line x1="18" y1="6" x2="6" y2="18" 973 + ></line> 974 + <line x1="6" y1="6" x2="18" y2="18" 975 + ></line> 976 + </svg> 977 + {/if} 978 + </button> 979 + </div> 980 + {:else} 981 <TrackItem 982 {track} 983 index={i} ··· 988 hideAlbum={true} 989 excludePlaylistId={playlist.id} 990 /> 991 + {/if} 992 + {/each} 993 + {#if isEditMode && isOwner} 994 + <button 995 + class="add-track-row" 996 + onclick={() => (showSearch = true)} 997 + > 998 + <svg 999 + width="18" 1000 + height="18" 1001 + viewBox="0 0 24 24" 1002 + fill="none" 1003 + stroke="currentColor" 1004 + stroke-width="2" 1005 + stroke-linecap="round" 1006 + stroke-linejoin="round" 1007 + > 1008 + <line x1="12" y1="5" x2="12" y2="19"></line> 1009 + <line x1="5" y1="12" x2="19" y2="12"></line> 1010 + </svg> 1011 + add tracks 1012 + </button> 1013 + {/if} 1014 + </div> 1015 {/if} 1016 </div> 1017 </main> ··· 1019 1020 {#if showSearch} 1021 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 1022 + <div 1023 + class="modal-overlay" 1024 + role="presentation" 1025 + onclick={() => { 1026 + showSearch = false; 1027 + searchQuery = ""; 1028 + searchResults = []; 1029 + }} 1030 + > 1031 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 1032 + <div 1033 + class="modal search-modal" 1034 + role="dialog" 1035 + aria-modal="true" 1036 + aria-labelledby="add-tracks-title" 1037 + tabindex="-1" 1038 + onclick={(e) => e.stopPropagation()} 1039 + > 1040 <div class="modal-header"> 1041 <h3 id="add-tracks-title">add tracks</h3> 1042 + <button 1043 + class="close-btn" 1044 + aria-label="close" 1045 + onclick={() => { 1046 + showSearch = false; 1047 + searchQuery = ""; 1048 + searchResults = []; 1049 + }} 1050 + > 1051 + <svg 1052 + width="20" 1053 + height="20" 1054 + viewBox="0 0 24 24" 1055 + fill="none" 1056 + stroke="currentColor" 1057 + stroke-width="2" 1058 + stroke-linecap="round" 1059 + stroke-linejoin="round" 1060 + > 1061 <line x1="18" y1="6" x2="6" y2="18"></line> 1062 <line x1="6" y1="6" x2="18" y2="18"></line> 1063 </svg> 1064 </button> 1065 </div> 1066 <div class="search-input-wrapper"> 1067 + <svg 1068 + width="18" 1069 + height="18" 1070 + viewBox="0 0 24 24" 1071 + fill="none" 1072 + stroke="currentColor" 1073 + stroke-width="2" 1074 + stroke-linecap="round" 1075 + stroke-linejoin="round" 1076 + > 1077 <circle cx="11" cy="11" r="8"></circle> 1078 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 1079 </svg> ··· 1097 {#each searchResults as result} 1098 <div class="search-result-item"> 1099 {#if result.image_url} 1100 + <img 1101 + src={result.image_url} 1102 + alt="" 1103 + class="result-image" 1104 + /> 1105 {:else} 1106 <div class="result-image-placeholder"> 1107 + <svg 1108 + width="16" 1109 + height="16" 1110 + viewBox="0 0 24 24" 1111 + fill="none" 1112 + stroke="currentColor" 1113 + stroke-width="2" 1114 + stroke-linecap="round" 1115 + stroke-linejoin="round" 1116 + > 1117 <circle cx="12" cy="12" r="10"></circle> 1118 <circle cx="12" cy="12" r="3"></circle> 1119 </svg> ··· 1121 {/if} 1122 <div class="result-info"> 1123 <span class="result-title">{result.title}</span> 1124 + <span class="result-artist" 1125 + >{result.artist_display_name}</span 1126 + > 1127 </div> 1128 <button 1129 class="add-result-btn" ··· 1133 {#if addingTrack === result.id} 1134 <span class="spinner"></span> 1135 {:else} 1136 + <svg 1137 + width="18" 1138 + height="18" 1139 + viewBox="0 0 24 24" 1140 + fill="none" 1141 + stroke="currentColor" 1142 + stroke-width="2" 1143 + stroke-linecap="round" 1144 + stroke-linejoin="round" 1145 + > 1146 + <line x1="12" y1="5" x2="12" y2="19" 1147 + ></line> 1148 + <line x1="5" y1="12" x2="19" y2="12" 1149 + ></line> 1150 </svg> 1151 {/if} 1152 </button> ··· 1160 1161 {#if showDeleteConfirm} 1162 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 1163 + <div 1164 + class="modal-overlay" 1165 + role="presentation" 1166 + onclick={() => (showDeleteConfirm = false)} 1167 + > 1168 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 1169 + <div 1170 + class="modal" 1171 + role="alertdialog" 1172 + aria-modal="true" 1173 + aria-labelledby="delete-confirm-title" 1174 + tabindex="-1" 1175 + onclick={(e) => e.stopPropagation()} 1176 + > 1177 <div class="modal-header"> 1178 <h3 id="delete-confirm-title">delete playlist?</h3> 1179 </div> 1180 <div class="modal-body"> 1181 + <p> 1182 + are you sure you want to delete "{playlist.name}"? this 1183 + action cannot be undone. 1184 + </p> 1185 </div> 1186 <div class="modal-footer"> 1187 + <button 1188 + class="cancel-btn" 1189 + onclick={() => (showDeleteConfirm = false)} 1190 + disabled={deleting} 1191 + > 1192 cancel 1193 </button> 1194 + <button 1195 + class="confirm-btn danger" 1196 + onclick={deletePlaylist} 1197 + disabled={deleting} 1198 + > 1199 + {deleting ? "deleting..." : "delete"} 1200 </button> 1201 </div> 1202 </div> ··· 1207 .container { 1208 max-width: 1200px; 1209 margin: 0 auto; 1210 + padding: 0 1rem 1211 + calc( 1212 + var(--player-height, 120px) + 2rem + 1213 + env(safe-area-inset-bottom, 0px) 1214 + ) 1215 + 1rem; 1216 } 1217 1218 main { ··· 1246 color: var(--text-muted); 1247 } 1248 1249 + .playlist-art-wrapper { 1250 + position: relative; 1251 + width: 200px; 1252 + height: 200px; 1253 + flex-shrink: 0; 1254 + } 1255 + 1256 + button.playlist-art-wrapper { 1257 + background: none; 1258 + border: none; 1259 + padding: 0; 1260 + cursor: pointer; 1261 + font-family: inherit; 1262 + } 1263 + 1264 + button.playlist-art-wrapper.clickable:hover .art-edit-overlay { 1265 + opacity: 1; 1266 + } 1267 + 1268 + button.playlist-art-wrapper.clickable:hover .playlist-art, 1269 + button.playlist-art-wrapper.clickable:hover .playlist-art-placeholder { 1270 + filter: brightness(0.7); 1271 + } 1272 + 1273 + .art-edit-overlay { 1274 + position: absolute; 1275 + inset: 0; 1276 + display: flex; 1277 + flex-direction: column; 1278 + align-items: center; 1279 + justify-content: center; 1280 + gap: 0.5rem; 1281 + color: white; 1282 + opacity: 0; 1283 + transition: opacity 0.2s; 1284 + pointer-events: none; 1285 + border-radius: 8px; 1286 + font-family: inherit; 1287 + } 1288 + 1289 + .art-edit-overlay span { 1290 + font-family: inherit; 1291 + font-size: 0.85rem; 1292 + font-weight: 500; 1293 + } 1294 + 1295 .playlist-info-wrapper { 1296 flex: 1; 1297 display: flex; ··· 1338 hyphens: auto; 1339 } 1340 1341 + .playlist-title-input { 1342 + font-size: 3rem; 1343 + font-weight: 700; 1344 + font-family: inherit; 1345 + margin: 0; 1346 + color: var(--text-primary); 1347 + line-height: 1.1; 1348 + background: transparent; 1349 + border: none; 1350 + border-bottom: 2px solid var(--accent); 1351 + outline: none; 1352 + width: 100%; 1353 + padding: 0; 1354 + } 1355 + 1356 + .playlist-title-input::placeholder { 1357 + color: var(--text-muted); 1358 + } 1359 + 1360 .playlist-meta { 1361 display: flex; 1362 align-items: center; ··· 1405 color: #ef4444; 1406 } 1407 1408 + .icon-btn.active { 1409 + border-color: var(--accent); 1410 + color: var(--accent); 1411 + background: color-mix(in srgb, var(--accent) 10%, transparent); 1412 + } 1413 + 1414 /* playlist actions */ 1415 .playlist-actions { 1416 display: flex; ··· 1419 } 1420 1421 .play-button, 1422 + .queue-button { 1423 padding: 0.75rem 1.5rem; 1424 border-radius: 24px; 1425 font-weight: 600; ··· 1442 transform: scale(1.05); 1443 } 1444 1445 + .queue-button { 1446 background: transparent; 1447 color: var(--text-primary); 1448 border: 1px solid var(--border-default); 1449 } 1450 1451 + .queue-button:hover { 1452 border-color: var(--accent); 1453 color: var(--accent); 1454 } 1455 1456 .spinner { ··· 1469 /* tracks section */ 1470 .tracks-section { 1471 margin-top: 2rem; 1472 + padding-bottom: calc( 1473 + var(--player-height, 120px) + env(safe-area-inset-bottom, 0px) 1474 + ); 1475 } 1476 1477 .section-heading { ··· 1552 min-width: 0; 1553 } 1554 1555 + .remove-track-btn { 1556 + display: flex; 1557 + align-items: center; 1558 + justify-content: center; 1559 + padding: 0.5rem; 1560 + background: transparent; 1561 + border: 1px solid var(--border-default); 1562 + border-radius: 4px; 1563 + color: var(--text-muted); 1564 + cursor: pointer; 1565 + transition: all 0.2s; 1566 + flex-shrink: 0; 1567 + width: 36px; 1568 + height: 36px; 1569 + } 1570 + 1571 + .remove-track-btn:hover:not(:disabled) { 1572 + border-color: #ef4444; 1573 + color: #ef4444; 1574 + background: color-mix(in srgb, #ef4444 10%, transparent); 1575 + } 1576 + 1577 + .remove-track-btn:disabled { 1578 + opacity: 0.6; 1579 + cursor: not-allowed; 1580 + } 1581 + 1582 + .remove-track-btn .spinner { 1583 + animation: spin 1s linear infinite; 1584 + } 1585 + 1586 + .add-track-row { 1587 + display: flex; 1588 + align-items: center; 1589 + justify-content: center; 1590 + gap: 0.5rem; 1591 + padding: 0.75rem 1rem; 1592 + margin-top: 0.5rem; 1593 + background: transparent; 1594 + border: 1px dashed var(--border-default); 1595 + border-radius: 8px; 1596 + color: var(--text-tertiary); 1597 + font-family: inherit; 1598 + font-size: 0.9rem; 1599 + cursor: pointer; 1600 + transition: all 0.2s; 1601 + } 1602 + 1603 + .add-track-row:hover { 1604 + border-color: var(--accent); 1605 + color: var(--accent); 1606 + background: color-mix(in srgb, var(--accent) 5%, transparent); 1607 + } 1608 + 1609 /* empty state */ 1610 .empty-state { 1611 display: flex; ··· 1923 } 1924 } 1925 1926 @media (max-width: 768px) { 1927 .playlist-hero { 1928 flex-direction: column; ··· 1953 align-items: center; 1954 } 1955 1956 + .playlist-title, 1957 + .playlist-title-input { 1958 font-size: 2rem; 1959 } 1960 ··· 1969 } 1970 1971 .play-button, 1972 + .queue-button { 1973 width: 100%; 1974 justify-content: center; 1975 + } 1976 + 1977 + .playlist-art-wrapper { 1978 + width: 160px; 1979 + height: 160px; 1980 } 1981 } 1982 ··· 1991 height: 140px; 1992 } 1993 1994 + .playlist-art-wrapper { 1995 + width: 140px; 1996 + height: 140px; 1997 + } 1998 + 1999 + .playlist-title, 2000 + .playlist-title-input { 2001 font-size: 1.75rem; 2002 } 2003 ··· 2006 flex-wrap: wrap; 2007 } 2008 } 2009 </style>