Compare changes

Choose any two refs to compare.

Changed files
+591 -135
.claude
commands
backend
src
backend
docs
frontend
src
lib
routes
portal
track
+65 -22
.claude/commands/status-update.md
··· 1 # status update 2 3 - update STATUS.md after completing significant work. 4 5 - ## when to update 6 7 - after shipping something notable: 8 - - new features or endpoints 9 - - bug fixes worth documenting 10 - - architectural changes 11 - - deployment/infrastructure changes 12 - - incidents and their resolutions 13 14 - **tip**: after running `/deploy`, consider running `/status-update` to document what shipped. 15 16 - ## how to update 17 18 - 1. add a new subsection under `## recent work` with today's date 19 - 2. describe what shipped, why it matters, and any relevant PR numbers 20 - 3. update `## immediate priorities` if priorities changed 21 - 4. update `## technical state` if architecture changed 22 23 - ## structure 24 25 - STATUS.md follows this structure: 26 - - **long-term vision** - why the project exists 27 - - **recent work** - chronological log of what shipped (newest first) 28 - - **immediate priorities** - what's next 29 - - **technical state** - architecture, what's working, known issues 30 31 - old content is automatically archived to `.status_history/` - you don't need to manage this. 32 33 ## tone 34 35 - direct, technical, honest about limitations. useful for someone with no prior context.
··· 1 # status update 2 3 + update STATUS.md to reflect recent work. 4 5 + ## workflow 6 + 7 + ### 1. understand current state 8 9 + read STATUS.md to understand: 10 + - what's already documented in `## recent work` 11 + - the last update date (noted at the bottom) 12 + - current priorities and known issues 13 + 14 + ### 2. find undocumented work 15 + 16 + ```bash 17 + # find the last STATUS.md update 18 + git log --oneline -1 -- STATUS.md 19 + 20 + # get all commits since then 21 + git log --oneline <last-status-commit>..HEAD 22 + ``` 23 + 24 + for each significant commit or PR: 25 + - read the commit message and changed files 26 + - understand WHY the change was made, not just what changed 27 + - note architectural decisions, trade-offs, or lessons learned 28 + 29 + ### 3. decide what to document 30 + 31 + not everything needs documentation. focus on: 32 + - **features**: new capabilities users or developers can use 33 + - **fixes**: bugs that affected users, especially if they might recur 34 + - **architecture**: changes to how systems connect or data flows 35 + - **decisions**: trade-offs made and why (future readers need context) 36 + - **incidents**: what broke, why, and how it was resolved 37 + 38 + skip: 39 + - routine maintenance (dependency bumps, typo fixes) 40 + - work-in-progress that didn't ship 41 + - changes already well-documented in the PR 42 + 43 + ### 4. write the update 44 + 45 + add a new subsection under `## recent work` following existing patterns: 46 47 + ```markdown 48 + #### brief title (PRs #NNN, date) 49 50 + **why**: the problem or motivation (1-2 sentences) 51 52 + **what shipped**: 53 + - concrete changes users or developers will notice 54 + - link to relevant docs if applicable 55 56 + **technical notes** (if architectural): 57 + - decisions made and why 58 + - trade-offs accepted 59 + ``` 60 61 + ### 5. update other sections if needed 62 63 + - `## priorities` - if focus has shifted 64 + - `## known issues` - if bugs were fixed or discovered 65 + - `## technical state` - if architecture changed 66 67 ## tone 68 69 + write for someone with no prior context who needs to understand: 70 + - what changed 71 + - why it matters 72 + - why this approach was chosen over alternatives 73 + 74 + be direct and technical. avoid marketing language. 75 + 76 + ## after updating 77 + 78 + commit the STATUS.md changes and open a PR for review.
+22 -1
STATUS.md
··· 79 80 --- 81 82 #### artist bio links (PRs #700-701, Jan 2) 83 84 **links in artist bios now render as clickable** - supports full URLs and bare domains (e.g., "example.com"): ··· 347 348 --- 349 350 - this is a living document. last updated 2026-01-06.
··· 79 80 --- 81 82 + #### auth stabilization (PRs #734-736, Jan 6-7) 83 + 84 + **why**: multi-account support introduced edge cases where auth state could become inconsistent between frontend components, and sessions could outlive their refresh tokens. 85 + 86 + **session expiry alignment** (PR #734): 87 + - sessions now track refresh token lifetime and respect it during validation 88 + - prevents sessions from appearing valid after their underlying OAuth grant expires 89 + - dev token expiration handling aligned with same pattern 90 + 91 + **queue auth boundary fix** (PR #735): 92 + - queue component now uses shared layout auth state instead of localStorage session IDs 93 + - fixes race condition where queue could attempt authenticated requests before layout resolved auth 94 + - ensures remote queue snapshots don't inherit local update flags during hydration 95 + 96 + **playlist cover upload fix** (PR #736): 97 + - `R2Storage.save()` was rejecting `BytesIO` objects due to beartype's strict `BinaryIO` protocol checking 98 + - changed type hint to `BinaryIO | BytesIO` to explicitly accept both 99 + - found via Logfire: only 2 failures in production, both on Jan 3 100 + 101 + --- 102 + 103 #### artist bio links (PRs #700-701, Jan 2) 104 105 **links in artist bios now render as clickable** - supports full URLs and bare domains (e.g., "example.com"): ··· 368 369 --- 370 371 + this is a living document. last updated 2026-01-07.
+5 -2
backend/src/backend/_internal/background.py
··· 92 ) 93 yield docket 94 finally: 95 - # cancel the worker task and wait for it to finish 96 if worker_task: 97 worker_task.cancel() 98 try: 99 - await worker_task 100 except asyncio.CancelledError: 101 logger.debug("docket worker task cancelled") 102 # clear global after worker is fully stopped
··· 92 ) 93 yield docket 94 finally: 95 + # cancel the worker task with timeout to avoid hanging on shutdown 96 if worker_task: 97 worker_task.cancel() 98 try: 99 + # wait briefly for clean shutdown, but don't block forever 100 + await asyncio.wait_for(worker_task, timeout=2.0) 101 + except TimeoutError: 102 + logger.warning("docket worker did not stop within timeout") 103 except asyncio.CancelledError: 104 logger.debug("docket worker task cancelled") 105 # clear global after worker is fully stopped
+2 -1
backend/src/backend/api/tracks/metadata_service.py
··· 6 from io import BytesIO 7 from typing import TYPE_CHECKING, Any 8 9 - from fastapi import HTTPException, UploadFile 10 from sqlalchemy.ext.asyncio import AsyncSession 11 from sqlalchemy.orm import attributes 12 13 from backend._internal.atproto.handles import resolve_handle 14 from backend._internal.image import ImageFormat
··· 6 from io import BytesIO 7 from typing import TYPE_CHECKING, Any 8 9 + from fastapi import HTTPException 10 from sqlalchemy.ext.asyncio import AsyncSession 11 from sqlalchemy.orm import attributes 12 + from starlette.datastructures import UploadFile 13 14 from backend._internal.atproto.handles import resolve_handle 15 from backend._internal.image import ImageFormat
+22 -4
backend/src/backend/api/tracks/mutations.py
··· 180 Form(description="JSON object for supporter gating, or 'null' to remove"), 181 ] = None, 182 image: UploadFile | None = File(None), 183 ) -> TrackResponse: 184 """Update track metadata (only by owner).""" 185 result = await db.execute( ··· 250 251 image_changed = False 252 image_url = None 253 - if image and image.filename: 254 image_id, image_url = await upload_track_image(image) 255 256 if track.image_id: ··· 305 try: 306 await _update_atproto_record(track, auth_session, image_url) 307 except Exception as exc: 308 - logger.error( 309 - f"failed to update ATProto record for track {track.id}: {exc}", 310 - exc_info=True, 311 ) 312 await db.rollback() 313 raise HTTPException(
··· 180 Form(description="JSON object for supporter gating, or 'null' to remove"), 181 ] = None, 182 image: UploadFile | None = File(None), 183 + remove_image: Annotated[ 184 + str | None, 185 + Form(description="Set to 'true' to remove artwork"), 186 + ] = None, 187 ) -> TrackResponse: 188 """Update track metadata (only by owner).""" 189 result = await db.execute( ··· 254 255 image_changed = False 256 image_url = None 257 + 258 + # handle image removal 259 + if remove_image and remove_image.lower() == "true" and track.image_id: 260 + # only delete image from R2 if album doesn't share it 261 + album_shares_image = ( 262 + track.album_rel and track.album_rel.image_id == track.image_id 263 + ) 264 + if not album_shares_image: 265 + with contextlib.suppress(Exception): 266 + await storage.delete(track.image_id) 267 + track.image_id = None 268 + track.image_url = None 269 + image_changed = True 270 + elif image and image.filename: 271 + # handle image upload/replacement 272 image_id, image_url = await upload_track_image(image) 273 274 if track.image_id: ··· 323 try: 324 await _update_atproto_record(track, auth_session, image_url) 325 except Exception as exc: 326 + logfire.exception( 327 + "failed to update ATProto record", 328 + track_id=track.id, 329 ) 330 await db.rollback() 331 raise HTTPException(
+10 -3
backend/src/backend/main.py
··· 1 """relay fastapi application.""" 2 3 import logging 4 import re 5 import warnings ··· 157 app.state.docket = docket 158 yield 159 160 - # shutdown: cleanup resources 161 - await notification_service.shutdown() 162 - await queue_service.shutdown() 163 164 165 app = FastAPI(
··· 1 """relay fastapi application.""" 2 3 + import asyncio 4 import logging 5 import re 6 import warnings ··· 158 app.state.docket = docket 159 yield 160 161 + # shutdown: cleanup resources with timeouts to avoid hanging 162 + try: 163 + await asyncio.wait_for(notification_service.shutdown(), timeout=2.0) 164 + except TimeoutError: 165 + logging.warning("notification_service.shutdown() timed out") 166 + try: 167 + await asyncio.wait_for(queue_service.shutdown(), timeout=2.0) 168 + except TimeoutError: 169 + logging.warning("queue_service.shutdown() timed out") 170 171 172 app = FastAPI(
+53
docs/frontend/state-management.md
··· 133 2. no console errors about "Cannot access X before initialization" 134 3. UI reflects current variable value 135 136 ## global state management 137 138 ### overview
··· 133 2. no console errors about "Cannot access X before initialization" 134 3. UI reflects current variable value 135 136 + ### waiting for async conditions with `$effect` 137 + 138 + when you need to perform an action after some async condition is met (like audio being ready), **don't rely on event listeners** - they may not attach in time if the target element doesn't exist yet or the event fires before your listener is registered. 139 + 140 + **instead, use a reactive `$effect` that watches for the conditions to be met:** 141 + 142 + ```typescript 143 + // โŒ WRONG - event listener may not attach in time 144 + onMount(() => { 145 + queue.playNow(track); // triggers async loading in Player component 146 + 147 + // player.audioElement might be undefined here! 148 + // even if it exists, loadedmetadata may fire before this runs 149 + player.audioElement?.addEventListener('loadedmetadata', () => { 150 + player.audioElement.currentTime = seekTime; 151 + }); 152 + }); 153 + ``` 154 + 155 + ```typescript 156 + // โœ… CORRECT - reactive effect waits for conditions 157 + let pendingSeekMs = $state<number | null>(null); 158 + 159 + onMount(() => { 160 + pendingSeekMs = 11000; // store the pending action 161 + queue.playNow(track); // trigger the async operation 162 + }); 163 + 164 + // effect runs whenever dependencies change, including when audio becomes ready 165 + $effect(() => { 166 + if ( 167 + pendingSeekMs !== null && 168 + player.currentTrack?.id === track.id && 169 + player.audioElement && 170 + player.audioElement.readyState >= 1 171 + ) { 172 + player.audioElement.currentTime = pendingSeekMs / 1000; 173 + pendingSeekMs = null; // clear after performing action 174 + } 175 + }); 176 + ``` 177 + 178 + **why this works:** 179 + - `$effect` re-runs whenever any of its dependencies change 180 + - when `player.audioElement` becomes available and ready, the effect fires 181 + - no race condition - the effect will catch the ready state even if it happened "in the past" 182 + - setting `pendingSeekMs = null` ensures the action only runs once 183 + 184 + **use this pattern when:** 185 + - waiting for DOM elements to exist 186 + - waiting for async operations to complete 187 + - coordinating between components that load independently 188 + 189 ## global state management 190 191 ### overview
+2 -2
frontend/src/lib/queue.svelte.ts
··· 416 this.schedulePush(); 417 } 418 419 - playNow(track: Track) { 420 - this.lastUpdateWasLocal = true; 421 const upNext = this.tracks.slice(this.currentIndex + 1); 422 this.tracks = [track, ...upNext]; 423 this.originalOrder = [...this.tracks];
··· 416 this.schedulePush(); 417 } 418 419 + playNow(track: Track, autoPlay = true) { 420 + this.lastUpdateWasLocal = autoPlay; 421 const upNext = this.tracks.slice(this.currentIndex + 1); 422 this.tracks = [track, ...upNext]; 423 this.originalOrder = [...this.tracks];
+359 -96
frontend/src/routes/portal/+page.svelte
··· 28 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 29 let editTags = $state<string[]>([]); 30 let editImageFile = $state<File | null>(null); 31 let editSupportGate = $state(false); 32 let hasUnresolvedEditFeaturesInput = $state(false); 33 ··· 328 editFeaturedArtists = []; 329 editTags = []; 330 editImageFile = null; 331 editSupportGate = false; 332 } 333 ··· 351 } else { 352 formData.append('support_gate', 'null'); 353 } 354 - if (editImageFile) { 355 formData.append('image', editImageFile); 356 } 357 ··· 730 /> 731 </div> 732 <div class="edit-field-group"> 733 - <label for="edit-image" class="edit-label">artwork (optional)</label> 734 - {#if track.image_url && !editImageFile} 735 - <div class="current-image-preview"> 736 - <img src={track.image_url} alt="current artwork" /> 737 - <span class="current-image-label">current artwork</span> 738 - </div> 739 - {/if} 740 - <input 741 - id="edit-image" 742 - type="file" 743 - accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif" 744 - onchange={(e) => { 745 - const target = e.target as HTMLInputElement; 746 - editImageFile = target.files?.[0] ?? null; 747 - }} 748 - class="edit-input" 749 - /> 750 - {#if editImageFile} 751 - <p class="file-info">{editImageFile.name} (will replace current)</p> 752 - {/if} 753 </div> 754 {#if atprotofansEligible || track.support_gate} 755 <div class="edit-field-group"> ··· 771 </div> 772 <div class="edit-actions"> 773 <button 774 - class="action-btn save-btn" 775 - onclick={() => saveTrackEdit(track.id)} 776 - disabled={hasUnresolvedEditFeaturesInput} 777 - title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"} 778 > 779 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 780 - <polyline points="20 6 9 17 4 12"></polyline> 781 - </svg> 782 </button> 783 <button 784 - class="action-btn cancel-btn" 785 - onclick={cancelEdit} 786 - title="cancel" 787 > 788 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 789 - <line x1="18" y1="6" x2="6" y2="18"></line> 790 - <line x1="6" y1="6" x2="18" y2="18"></line> 791 - </svg> 792 </button> 793 </div> 794 </div> ··· 880 </div> 881 <div class="track-actions"> 882 <button 883 - class="action-btn edit-btn" 884 onclick={() => startEditTrack(track)} 885 - title="edit track" 886 > 887 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 888 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 889 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 890 </svg> 891 </button> 892 <button 893 - class="action-btn delete-btn" 894 onclick={() => deleteTrack(track.id, track.title)} 895 - title="delete track" 896 > 897 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 898 <polyline points="3 6 5 6 21 6"></polyline> 899 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> 900 </svg> 901 </button> 902 </div> 903 {/if} ··· 1451 cursor: not-allowed; 1452 } 1453 1454 - .file-info { 1455 - margin-top: 0.5rem; 1456 - font-size: var(--text-sm); 1457 - color: var(--text-muted); 1458 - } 1459 - 1460 - button { 1461 width: 100%; 1462 padding: 0.75rem; 1463 background: var(--accent); ··· 1471 transition: all 0.2s; 1472 } 1473 1474 - button:hover:not(:disabled) { 1475 background: var(--accent-hover); 1476 transform: translateY(-1px); 1477 box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); 1478 } 1479 1480 - button:disabled { 1481 opacity: 0.5; 1482 cursor: not-allowed; 1483 transform: none; 1484 } 1485 1486 - button:active:not(:disabled) { 1487 transform: translateY(0); 1488 } 1489 ··· 1787 align-self: flex-start; 1788 } 1789 1790 - .action-btn { 1791 - display: flex; 1792 align-items: center; 1793 - justify-content: center; 1794 - width: 32px; 1795 - height: 32px; 1796 - padding: 0; 1797 background: transparent; 1798 border: 1px solid var(--border-default); 1799 - border-radius: var(--radius-base); 1800 color: var(--text-tertiary); 1801 cursor: pointer; 1802 transition: all 0.15s; 1803 - flex-shrink: 0; 1804 } 1805 1806 - .action-btn svg { 1807 - flex-shrink: 0; 1808 } 1809 1810 - .action-btn:hover { 1811 transform: none; 1812 box-shadow: none; 1813 } 1814 1815 - .edit-btn:hover { 1816 - background: color-mix(in srgb, var(--accent) 12%, transparent); 1817 - border-color: var(--accent); 1818 color: var(--accent); 1819 } 1820 1821 - .delete-btn:hover { 1822 - background: color-mix(in srgb, var(--error) 12%, transparent); 1823 - border-color: var(--error); 1824 - color: var(--error); 1825 } 1826 1827 - .save-btn:hover { 1828 - background: color-mix(in srgb, var(--success) 12%, transparent); 1829 - border-color: var(--success); 1830 - color: var(--success); 1831 - } 1832 - 1833 - .cancel-btn:hover { 1834 - background: color-mix(in srgb, var(--text-tertiary) 12%, transparent); 1835 - border-color: var(--text-tertiary); 1836 - color: var(--text-secondary); 1837 } 1838 1839 .edit-input { ··· 1847 font-family: inherit; 1848 } 1849 1850 - .current-image-preview { 1851 display: flex; 1852 align-items: center; 1853 - gap: 0.75rem; 1854 - padding: 0.5rem; 1855 background: var(--bg-primary); 1856 border: 1px solid var(--border-default); 1857 - border-radius: var(--radius-sm); 1858 - margin-bottom: 0.5rem; 1859 } 1860 1861 - .current-image-preview img { 1862 - width: 48px; 1863 - height: 48px; 1864 - border-radius: var(--radius-sm); 1865 object-fit: cover; 1866 } 1867 1868 - .current-image-label { 1869 color: var(--text-tertiary); 1870 font-size: var(--text-sm); 1871 } 1872 1873 .edit-input:focus { ··· 2424 .track-actions { 2425 margin-left: 0.5rem; 2426 gap: 0.35rem; 2427 } 2428 2429 - .action-btn { 2430 - width: 30px; 2431 - height: 30px; 2432 } 2433 2434 - .action-btn svg { 2435 - width: 14px; 2436 - height: 14px; 2437 } 2438 2439 /* edit mode mobile */ ··· 2455 } 2456 2457 .edit-actions { 2458 - gap: 0.35rem; 2459 } 2460 2461 /* data section mobile */
··· 28 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 29 let editTags = $state<string[]>([]); 30 let editImageFile = $state<File | null>(null); 31 + let editImagePreviewUrl = $state<string | null>(null); 32 + let editRemoveImage = $state(false); 33 let editSupportGate = $state(false); 34 let hasUnresolvedEditFeaturesInput = $state(false); 35 ··· 330 editFeaturedArtists = []; 331 editTags = []; 332 editImageFile = null; 333 + if (editImagePreviewUrl) { 334 + URL.revokeObjectURL(editImagePreviewUrl); 335 + } 336 + editImagePreviewUrl = null; 337 + editRemoveImage = false; 338 editSupportGate = false; 339 } 340 ··· 358 } else { 359 formData.append('support_gate', 'null'); 360 } 361 + // handle artwork: remove, replace, or leave unchanged 362 + if (editRemoveImage) { 363 + formData.append('remove_image', 'true'); 364 + } else if (editImageFile) { 365 formData.append('image', editImageFile); 366 } 367 ··· 740 /> 741 </div> 742 <div class="edit-field-group"> 743 + <span class="edit-label">artwork (optional)</span> 744 + <div class="artwork-editor"> 745 + {#if editImagePreviewUrl} 746 + <!-- New image selected - show preview --> 747 + <div class="artwork-preview"> 748 + <img src={editImagePreviewUrl} alt="new artwork preview" /> 749 + <div class="artwork-preview-overlay"> 750 + <button 751 + type="button" 752 + class="artwork-action-btn" 753 + onclick={() => { 754 + editImageFile = null; 755 + if (editImagePreviewUrl) { 756 + URL.revokeObjectURL(editImagePreviewUrl); 757 + } 758 + editImagePreviewUrl = null; 759 + }} 760 + title="remove selection" 761 + > 762 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 763 + <line x1="18" y1="6" x2="6" y2="18"></line> 764 + <line x1="6" y1="6" x2="18" y2="18"></line> 765 + </svg> 766 + </button> 767 + </div> 768 + </div> 769 + <span class="artwork-status">new artwork selected</span> 770 + {:else if editRemoveImage} 771 + <!-- User chose to remove artwork --> 772 + <div class="artwork-removed"> 773 + <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 774 + <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 775 + <line x1="9" y1="9" x2="15" y2="15"></line> 776 + <line x1="15" y1="9" x2="9" y2="15"></line> 777 + </svg> 778 + <span>artwork will be removed</span> 779 + <button 780 + type="button" 781 + class="undo-remove-btn" 782 + onclick={() => { editRemoveImage = false; }} 783 + > 784 + undo 785 + </button> 786 + </div> 787 + {:else if track.image_url} 788 + <!-- Current artwork exists --> 789 + <div class="artwork-preview"> 790 + <img src={track.image_url} alt="current artwork" /> 791 + <div class="artwork-preview-overlay"> 792 + <button 793 + type="button" 794 + class="artwork-action-btn" 795 + onclick={() => { editRemoveImage = true; }} 796 + title="remove artwork" 797 + > 798 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 799 + <polyline points="3 6 5 6 21 6"></polyline> 800 + <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> 801 + </svg> 802 + </button> 803 + </div> 804 + </div> 805 + <span class="artwork-status current">current artwork</span> 806 + {:else} 807 + <!-- No artwork --> 808 + <div class="artwork-empty"> 809 + <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 810 + <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 811 + <circle cx="8.5" cy="8.5" r="1.5"></circle> 812 + <polyline points="21 15 16 10 5 21"></polyline> 813 + </svg> 814 + <span>no artwork</span> 815 + </div> 816 + {/if} 817 + {#if !editRemoveImage} 818 + <label class="artwork-upload-btn"> 819 + <input 820 + type="file" 821 + accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif" 822 + onchange={(e) => { 823 + const target = e.target as HTMLInputElement; 824 + const file = target.files?.[0]; 825 + if (file) { 826 + editImageFile = file; 827 + if (editImagePreviewUrl) { 828 + URL.revokeObjectURL(editImagePreviewUrl); 829 + } 830 + editImagePreviewUrl = URL.createObjectURL(file); 831 + } 832 + }} 833 + /> 834 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 835 + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> 836 + <polyline points="17 8 12 3 7 8"></polyline> 837 + <line x1="12" y1="3" x2="12" y2="15"></line> 838 + </svg> 839 + {track.image_url || editImagePreviewUrl ? 'replace' : 'upload'} 840 + </label> 841 + {/if} 842 + </div> 843 </div> 844 {#if atprotofansEligible || track.support_gate} 845 <div class="edit-field-group"> ··· 861 </div> 862 <div class="edit-actions"> 863 <button 864 + type="button" 865 + class="edit-cancel-btn" 866 + onclick={cancelEdit} 867 > 868 + cancel 869 </button> 870 <button 871 + type="button" 872 + class="edit-save-btn" 873 + onclick={() => saveTrackEdit(track.id)} 874 + disabled={hasUnresolvedEditFeaturesInput} 875 + title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"} 876 > 877 + save changes 878 </button> 879 </div> 880 </div> ··· 966 </div> 967 <div class="track-actions"> 968 <button 969 + type="button" 970 + class="track-action-btn edit" 971 onclick={() => startEditTrack(track)} 972 > 973 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 974 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 975 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 976 </svg> 977 + edit 978 </button> 979 <button 980 + type="button" 981 + class="track-action-btn delete" 982 onclick={() => deleteTrack(track.id, track.title)} 983 > 984 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 985 <polyline points="3 6 5 6 21 6"></polyline> 986 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> 987 </svg> 988 + delete 989 </button> 990 </div> 991 {/if} ··· 1539 cursor: not-allowed; 1540 } 1541 1542 + /* form submit buttons only */ 1543 + form button[type="submit"] { 1544 width: 100%; 1545 padding: 0.75rem; 1546 background: var(--accent); ··· 1554 transition: all 0.2s; 1555 } 1556 1557 + form button[type="submit"]:hover:not(:disabled) { 1558 background: var(--accent-hover); 1559 transform: translateY(-1px); 1560 box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); 1561 } 1562 1563 + form button[type="submit"]:disabled { 1564 opacity: 0.5; 1565 cursor: not-allowed; 1566 transform: none; 1567 } 1568 1569 + form button[type="submit"]:active:not(:disabled) { 1570 transform: translateY(0); 1571 } 1572 ··· 1870 align-self: flex-start; 1871 } 1872 1873 + /* track action buttons (edit/delete in non-editing state) */ 1874 + .track-action-btn { 1875 + display: inline-flex; 1876 align-items: center; 1877 + gap: 0.35rem; 1878 + padding: 0.4rem 0.65rem; 1879 background: transparent; 1880 border: 1px solid var(--border-default); 1881 + border-radius: var(--radius-full); 1882 color: var(--text-tertiary); 1883 + font-size: var(--text-sm); 1884 + font-family: inherit; 1885 + font-weight: 500; 1886 cursor: pointer; 1887 transition: all 0.15s; 1888 + white-space: nowrap; 1889 + width: auto; 1890 + } 1891 + 1892 + .track-action-btn:hover { 1893 + transform: none; 1894 + box-shadow: none; 1895 + border-color: var(--border-emphasis); 1896 + color: var(--text-secondary); 1897 + } 1898 + 1899 + .track-action-btn.delete:hover { 1900 + color: var(--text-secondary); 1901 + } 1902 + 1903 + /* edit mode action buttons */ 1904 + .edit-actions { 1905 + display: flex; 1906 + gap: 0.75rem; 1907 + justify-content: flex-end; 1908 + padding-top: 0.75rem; 1909 + border-top: 1px solid var(--border-subtle); 1910 + margin-top: 0.5rem; 1911 } 1912 1913 + .edit-cancel-btn { 1914 + padding: 0.6rem 1.25rem; 1915 + background: transparent; 1916 + border: 1px solid var(--border-default); 1917 + border-radius: var(--radius-base); 1918 + color: var(--text-secondary); 1919 + font-size: var(--text-base); 1920 + font-weight: 500; 1921 + font-family: inherit; 1922 + cursor: pointer; 1923 + transition: all 0.15s; 1924 + width: auto; 1925 } 1926 1927 + .edit-cancel-btn:hover { 1928 + border-color: var(--text-tertiary); 1929 + background: var(--bg-hover); 1930 transform: none; 1931 box-shadow: none; 1932 } 1933 1934 + .edit-save-btn { 1935 + padding: 0.6rem 1.25rem; 1936 + background: transparent; 1937 + border: 1px solid var(--accent); 1938 + border-radius: var(--radius-base); 1939 color: var(--accent); 1940 + font-size: var(--text-base); 1941 + font-weight: 500; 1942 + font-family: inherit; 1943 + cursor: pointer; 1944 + transition: all 0.15s; 1945 + width: auto; 1946 } 1947 1948 + .edit-save-btn:hover:not(:disabled) { 1949 + background: color-mix(in srgb, var(--accent) 8%, transparent); 1950 } 1951 1952 + .edit-save-btn:disabled { 1953 + opacity: 0.5; 1954 + cursor: not-allowed; 1955 } 1956 1957 .edit-input { ··· 1965 font-family: inherit; 1966 } 1967 1968 + /* artwork editor */ 1969 + .artwork-editor { 1970 display: flex; 1971 align-items: center; 1972 + gap: 1rem; 1973 + padding: 0.75rem; 1974 background: var(--bg-primary); 1975 border: 1px solid var(--border-default); 1976 + border-radius: var(--radius-base); 1977 } 1978 1979 + .artwork-preview { 1980 + position: relative; 1981 + width: 80px; 1982 + height: 80px; 1983 + border-radius: var(--radius-base); 1984 + overflow: hidden; 1985 + flex-shrink: 0; 1986 + } 1987 + 1988 + .artwork-preview img { 1989 + width: 100%; 1990 + height: 100%; 1991 object-fit: cover; 1992 } 1993 1994 + .artwork-preview-overlay { 1995 + position: absolute; 1996 + inset: 0; 1997 + background: rgba(0, 0, 0, 0.5); 1998 + display: flex; 1999 + align-items: center; 2000 + justify-content: center; 2001 + opacity: 0; 2002 + transition: opacity 0.15s; 2003 + } 2004 + 2005 + .artwork-preview:hover .artwork-preview-overlay { 2006 + opacity: 1; 2007 + } 2008 + 2009 + .artwork-action-btn { 2010 + display: flex; 2011 + align-items: center; 2012 + justify-content: center; 2013 + width: 32px; 2014 + height: 32px; 2015 + padding: 0; 2016 + background: rgba(255, 255, 255, 0.15); 2017 + border: none; 2018 + border-radius: var(--radius-full); 2019 + color: white; 2020 + cursor: pointer; 2021 + transition: all 0.15s; 2022 + } 2023 + 2024 + .artwork-action-btn:hover { 2025 + background: var(--error); 2026 + transform: scale(1.1); 2027 + box-shadow: none; 2028 + } 2029 + 2030 + .artwork-status { 2031 + font-size: var(--text-sm); 2032 + color: var(--accent); 2033 + font-weight: 500; 2034 + } 2035 + 2036 + .artwork-status.current { 2037 color: var(--text-tertiary); 2038 + font-weight: 400; 2039 + } 2040 + 2041 + .artwork-removed { 2042 + display: flex; 2043 + flex-direction: column; 2044 + align-items: center; 2045 + gap: 0.5rem; 2046 + padding: 0.75rem 1rem; 2047 + color: var(--text-tertiary); 2048 + } 2049 + 2050 + .artwork-removed span { 2051 font-size: var(--text-sm); 2052 + } 2053 + 2054 + .undo-remove-btn { 2055 + padding: 0.25rem 0.75rem; 2056 + background: transparent; 2057 + border: 1px solid var(--border-default); 2058 + border-radius: var(--radius-full); 2059 + color: var(--accent); 2060 + font-size: var(--text-sm); 2061 + font-family: inherit; 2062 + cursor: pointer; 2063 + transition: all 0.15s; 2064 + width: auto; 2065 + } 2066 + 2067 + .undo-remove-btn:hover { 2068 + border-color: var(--accent); 2069 + background: color-mix(in srgb, var(--accent) 10%, transparent); 2070 + transform: none; 2071 + box-shadow: none; 2072 + } 2073 + 2074 + .artwork-empty { 2075 + display: flex; 2076 + flex-direction: column; 2077 + align-items: center; 2078 + gap: 0.5rem; 2079 + padding: 0.75rem 1rem; 2080 + color: var(--text-muted); 2081 + } 2082 + 2083 + .artwork-empty span { 2084 + font-size: var(--text-sm); 2085 + } 2086 + 2087 + .artwork-upload-btn { 2088 + display: inline-flex; 2089 + align-items: center; 2090 + gap: 0.4rem; 2091 + padding: 0.5rem 0.85rem; 2092 + background: transparent; 2093 + border: 1px solid var(--accent); 2094 + border-radius: var(--radius-full); 2095 + color: var(--accent); 2096 + font-size: var(--text-sm); 2097 + font-weight: 500; 2098 + cursor: pointer; 2099 + transition: all 0.15s; 2100 + margin-left: auto; 2101 + } 2102 + 2103 + .artwork-upload-btn:hover { 2104 + background: color-mix(in srgb, var(--accent) 12%, transparent); 2105 + } 2106 + 2107 + .artwork-upload-btn input { 2108 + display: none; 2109 } 2110 2111 .edit-input:focus { ··· 2662 .track-actions { 2663 margin-left: 0.5rem; 2664 gap: 0.35rem; 2665 + flex-direction: column; 2666 } 2667 2668 + .track-action-btn { 2669 + padding: 0.35rem 0.55rem; 2670 + font-size: var(--text-xs); 2671 } 2672 2673 + .track-action-btn svg { 2674 + width: 12px; 2675 + height: 12px; 2676 } 2677 2678 /* edit mode mobile */ ··· 2694 } 2695 2696 .edit-actions { 2697 + gap: 0.5rem; 2698 + flex-direction: column; 2699 + } 2700 + 2701 + .edit-cancel-btn, 2702 + .edit-save-btn { 2703 + width: 100%; 2704 + padding: 0.6rem; 2705 + font-size: var(--text-sm); 2706 + } 2707 + 2708 + /* artwork editor mobile */ 2709 + .artwork-editor { 2710 + flex-direction: column; 2711 + gap: 0.75rem; 2712 + padding: 0.65rem; 2713 + } 2714 + 2715 + .artwork-preview { 2716 + width: 64px; 2717 + height: 64px; 2718 + } 2719 + 2720 + .artwork-upload-btn { 2721 + margin-left: 0; 2722 } 2723 2724 /* data section mobile */
+51 -4
frontend/src/routes/track/[id]/+page.svelte
··· 1 <script lang="ts"> 2 import { fade } from 'svelte/transition'; 3 import { browser } from '$app/environment'; 4 import type { PageData } from './$types'; 5 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 6 import { API_URL } from '$lib/config'; ··· 231 } 232 } 233 234 function formatRelativeTime(isoString: string): string { 235 const date = new Date(isoString); 236 const now = new Date(); ··· 308 // track if we've loaded liked state for this track (separate from general load) 309 let likedStateLoadedForTrackId = $state<number | null>(null); 310 311 // reload data when navigating between track pages 312 // watch data.track.id (from server) not track.id (local state) 313 $effect(() => { ··· 324 editingCommentId = null; 325 editingCommentText = ''; 326 likedStateLoadedForTrackId = null; // reset liked state tracking 327 328 // sync track from server data 329 track = data.track; ··· 355 shareUrl = `${window.location.origin}/track/${track.id}`; 356 } 357 }); 358 </script> 359 360 <svelte:head> ··· 647 </div> 648 {:else} 649 <p class="comment-text">{#each parseTextWithLinks(comment.text) as segment}{#if segment.type === 'link'}<a href={segment.url} target="_blank" rel="noopener noreferrer" class="comment-link">{segment.url}</a>{:else}{segment.content}{/if}{/each}</p> 650 - {#if auth.user?.did === comment.user_did} 651 - <div class="comment-actions"> 652 <button class="comment-action-btn" onclick={() => startEditing(comment)}>edit</button> 653 <button class="comment-action-btn delete" onclick={() => deleteComment(comment.id)}>delete</button> 654 - </div> 655 - {/if} 656 {/if} 657 </div> 658 </div>
··· 1 <script lang="ts"> 2 import { fade } from 'svelte/transition'; 3 + import { onMount } from 'svelte'; 4 import { browser } from '$app/environment'; 5 + import { page } from '$app/stores'; 6 import type { PageData } from './$types'; 7 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 8 import { API_URL } from '$lib/config'; ··· 233 } 234 } 235 236 + async function copyCommentLink(timestampMs: number) { 237 + const seconds = Math.floor(timestampMs / 1000); 238 + const url = `${window.location.origin}/track/${track.id}?t=${seconds}`; 239 + await navigator.clipboard.writeText(url); 240 + toast.success('link copied'); 241 + } 242 + 243 function formatRelativeTime(isoString: string): string { 244 const date = new Date(isoString); 245 const now = new Date(); ··· 317 // track if we've loaded liked state for this track (separate from general load) 318 let likedStateLoadedForTrackId = $state<number | null>(null); 319 320 + // pending seek time from ?t= URL param (milliseconds) 321 + let pendingSeekMs = $state<number | null>(null); 322 + 323 // reload data when navigating between track pages 324 // watch data.track.id (from server) not track.id (local state) 325 $effect(() => { ··· 336 editingCommentId = null; 337 editingCommentText = ''; 338 likedStateLoadedForTrackId = null; // reset liked state tracking 339 + pendingSeekMs = null; // reset pending seek 340 341 // sync track from server data 342 track = data.track; ··· 368 shareUrl = `${window.location.origin}/track/${track.id}`; 369 } 370 }); 371 + 372 + // handle ?t= timestamp param for deep linking (youtube-style) 373 + onMount(() => { 374 + const t = $page.url.searchParams.get('t'); 375 + if (t) { 376 + const seconds = parseInt(t, 10); 377 + if (!isNaN(seconds) && seconds >= 0) { 378 + pendingSeekMs = seconds * 1000; 379 + // load the track without auto-playing (browser blocks autoplay without interaction) 380 + if (track.gated) { 381 + void playTrack(track); 382 + } else { 383 + queue.playNow(track, false); 384 + } 385 + } 386 + } 387 + }); 388 + 389 + // perform pending seek once track is loaded and ready 390 + $effect(() => { 391 + if ( 392 + pendingSeekMs !== null && 393 + player.currentTrack?.id === track.id && 394 + player.audioElement && 395 + player.audioElement.readyState >= 1 396 + ) { 397 + const seekTo = pendingSeekMs / 1000; 398 + pendingSeekMs = null; 399 + player.audioElement.currentTime = seekTo; 400 + // don't auto-play - browser policy blocks it without user interaction 401 + // user will click play themselves 402 + } 403 + }); 404 </script> 405 406 <svelte:head> ··· 693 </div> 694 {:else} 695 <p class="comment-text">{#each parseTextWithLinks(comment.text) as segment}{#if segment.type === 'link'}<a href={segment.url} target="_blank" rel="noopener noreferrer" class="comment-link">{segment.url}</a>{:else}{segment.content}{/if}{/each}</p> 696 + <div class="comment-actions"> 697 + <button class="comment-action-btn" onclick={() => copyCommentLink(comment.timestamp_ms)}>share</button> 698 + {#if auth.user?.did === comment.user_did} 699 <button class="comment-action-btn" onclick={() => startEditing(comment)}>edit</button> 700 <button class="comment-action-btn delete" onclick={() => deleteComment(comment.id)}>delete</button> 701 + {/if} 702 + </div> 703 {/if} 704 </div> 705 </div>