fix: reload data when navigating between detail pages of same type (#527)

* fix: reload data when navigating between detail pages of same type

SvelteKit reuses component instances when navigating between routes with
the same layout. This meant `onMount` didn't re-run when going from one
artist page to another, or one track page to another.

- replace `onMount` with `$effect` watching server data (data.artist.did,
data.track.id) to detect navigation
- reset local state and reload fresh data when route params change
- also: change "of music" to "of audio" in platform stats (not all
uploads are music - some are audiobooks, public domain recordings, etc)

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

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

* feat: add Tangled repo link and fix responsive header breakpoints

- Add Tangled link with sheep avatar to desktop header and mobile LinksMenu
- Consolidate breakpoints: desktop elements and mobile layout now switch at 1299px
- Previously stats/search/logout disappeared at 1499px but mobile didn't show until 1299px,
leaving a gap where users couldn't access those features

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

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

* fix: bump responsive breakpoint to 1399px to prevent stats/search collision

Switch to mobile layout earlier to avoid cramped margin elements

* fix: accent color on hover for nav-link and Tangled icon

- nav-link (feed/library) now uses accent color on hover
- Tangled icon gets accent-colored ring on hover
- Nudge search component 20px right to prevent overlap with stats

---------

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

authored by zzstoatzz.io Claude and committed by GitHub 894b3144 f2ba21bf

Changed files
+125 -24
frontend
src
lib
routes
track
u
[handle]
+33 -6
frontend/src/lib/components/Header.svelte
··· 65 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 66 </svg> 67 </a> 68 69 <!-- mobile: show menu button --> 70 <div class="mobile-only"> ··· 237 238 .search-left { 239 position: absolute; 240 - left: calc((100vw - var(--queue-width, 0px) - 800px) / 3); 241 top: 50%; 242 transform: translate(-50%, -50%); 243 transition: left 0.3s ease; ··· 270 } 271 272 .bluesky-link, 273 - .status-link { 274 display: flex; 275 align-items: center; 276 justify-content: center; 277 color: var(--text-secondary); 278 - transition: color 0.2s; 279 text-decoration: none; 280 flex-shrink: 0; 281 } ··· 288 color: var(--accent); 289 } 290 291 h1 { 292 font-size: 1.5rem; 293 margin: 0; ··· 325 } 326 327 .nav-link:hover { 328 - color: var(--text-primary); 329 background: var(--bg-tertiary); 330 border-color: var(--border-default); 331 } ··· 371 color: var(--bg-primary); 372 } 373 374 - /* Show LinksMenu (with stats) when sidebar is hidden */ 375 - @media (max-width: 1299px) { 376 .desktop-only { 377 display: none !important; 378 }
··· 65 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 66 </svg> 67 </a> 68 + <a 69 + href="https://tangled.org/@zzstoatzz.io/plyr.fm" 70 + target="_blank" 71 + rel="noopener noreferrer" 72 + class="tangled-link desktop-only" 73 + title="View source on Tangled" 74 + > 75 + <img src="https://cdn.bsky.app/img/avatar/plain/did:plc:wshs7t2adsemcrrd4snkeqli/bafkreif6z53z4ukqmdgwstspwh5asmhxheblcd2adisoccl4fflozc3kva@jpeg" alt="Tangled" width="20" height="20" class="tangled-icon" /> 76 + </a> 77 78 <!-- mobile: show menu button --> 79 <div class="mobile-only"> ··· 246 247 .search-left { 248 position: absolute; 249 + left: calc((100vw - var(--queue-width, 0px) - 800px) / 3 + 20px); 250 top: 50%; 251 transform: translate(-50%, -50%); 252 transition: left 0.3s ease; ··· 279 } 280 281 .bluesky-link, 282 + .status-link, 283 + .tangled-link { 284 display: flex; 285 align-items: center; 286 justify-content: center; 287 color: var(--text-secondary); 288 + transition: color 0.2s, opacity 0.2s; 289 text-decoration: none; 290 flex-shrink: 0; 291 } ··· 298 color: var(--accent); 299 } 300 301 + .tangled-icon { 302 + border-radius: 4px; 303 + opacity: 0.7; 304 + transition: opacity 0.2s, box-shadow 0.2s; 305 + } 306 + 307 + .tangled-link:hover .tangled-icon { 308 + opacity: 1; 309 + box-shadow: 0 0 0 2px var(--accent); 310 + } 311 + 312 h1 { 313 font-size: 1.5rem; 314 margin: 0; ··· 346 } 347 348 .nav-link:hover { 349 + color: var(--accent); 350 background: var(--bg-tertiary); 351 border-color: var(--border-default); 352 } ··· 392 color: var(--bg-primary); 393 } 394 395 + /* Hide margin-positioned elements and switch to mobile layout at the same breakpoint */ 396 + @media (max-width: 1399px) { 397 + .stats-left, 398 + .search-left, 399 + .logout-right { 400 + display: none !important; 401 + } 402 + 403 .desktop-only { 404 display: none !important; 405 }
+29
frontend/src/lib/components/LinksMenu.svelte
··· 102 <span class="link-title">status page</span> 103 </div> 104 </a> 105 </nav> 106 <PlatformStats variant="menu" /> 107 </div> ··· 223 224 .menu-link:hover svg { 225 color: var(--accent); 226 } 227 228 .link-info {
··· 102 <span class="link-title">status page</span> 103 </div> 104 </a> 105 + <a 106 + href="https://tangled.org/@zzstoatzz.io/plyr.fm" 107 + target="_blank" 108 + rel="noopener noreferrer" 109 + class="menu-link" 110 + > 111 + <img 112 + src="https://cdn.bsky.app/img/avatar/plain/did:plc:wshs7t2adsemcrrd4snkeqli/bafkreif6z53z4ukqmdgwstspwh5asmhxheblcd2adisoccl4fflozc3kva@jpeg" 113 + alt="Tangled" 114 + width="24" 115 + height="24" 116 + class="tangled-menu-icon" 117 + /> 118 + <div class="link-info"> 119 + <span class="link-title">source code</span> 120 + <span class="link-subtitle">tangled.org</span> 121 + </div> 122 + </a> 123 </nav> 124 <PlatformStats variant="menu" /> 125 </div> ··· 241 242 .menu-link:hover svg { 243 color: var(--accent); 244 + } 245 + 246 + .tangled-menu-icon { 247 + border-radius: 4px; 248 + opacity: 0.7; 249 + transition: opacity 0.2s, box-shadow 0.2s; 250 + } 251 + 252 + .menu-link:hover .tangled-menu-icon { 253 + opacity: 1; 254 + box-shadow: 0 0 0 2px var(--accent); 255 } 256 257 .link-info {
+2 -2
frontend/src/lib/components/PlatformStats.svelte
··· 45 </svg> 46 <span class="header-value">{stats.total_artists.toLocaleString()}</span> 47 </div> 48 - <div class="header-stat" title="{formatDuration(stats.total_duration_seconds)} of music"> 49 <svg class="header-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 50 <circle cx="12" cy="12" r="10"></circle> 51 <polyline points="12 6 12 12 16 14"></polyline> ··· 109 <polyline points="12 6 12 12 16 14"></polyline> 110 </svg> 111 <span class="stats-menu-value">{formatDuration(stats.total_duration_seconds)}</span> 112 - <span class="stats-menu-label">of music</span> 113 </div> 114 </div> 115 {/if}
··· 45 </svg> 46 <span class="header-value">{stats.total_artists.toLocaleString()}</span> 47 </div> 48 + <div class="header-stat" title="{formatDuration(stats.total_duration_seconds)} of audio"> 49 <svg class="header-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 50 <circle cx="12" cy="12" r="10"></circle> 51 <polyline points="12 6 12 12 16 14"></polyline> ··· 109 <polyline points="12 6 12 12 16 14"></polyline> 110 </svg> 111 <span class="stats-menu-value">{formatDuration(stats.total_duration_seconds)}</span> 112 + <span class="stats-menu-label">of audio</span> 113 </div> 114 </div> 115 {/if}
+31 -5
frontend/src/routes/track/[id]/+page.svelte
··· 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 import { fade } from 'svelte/transition'; 4 import type { PageData } from './$types'; 5 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 6 import { API_URL } from '$lib/config'; ··· 285 } 286 } 287 288 - onMount(async () => { 289 - if (auth.isAuthenticated) { 290 - await loadLikedState(); 291 } 292 - await loadComments(); 293 }); 294 295 let shareUrl = $state('');
··· 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'; ··· 285 } 286 } 287 288 + // track which track we've loaded data for to detect navigation 289 + let loadedForTrackId = $state<number | null>(null); 290 + 291 + // reload data when navigating between track pages 292 + // watch data.track.id (from server) not track.id (local state) 293 + $effect(() => { 294 + const currentId = data.track?.id; 295 + if (!currentId || !browser) return; 296 + 297 + // check if we navigated to a different track 298 + if (loadedForTrackId !== currentId) { 299 + // reset state for new track 300 + comments = []; 301 + loadingComments = true; 302 + commentsEnabled = null; 303 + newCommentText = ''; 304 + editingCommentId = null; 305 + editingCommentText = ''; 306 + 307 + // sync track from server data 308 + track = data.track; 309 + 310 + // mark as loaded for this track 311 + loadedForTrackId = currentId; 312 + 313 + // load fresh data 314 + if (auth.isAuthenticated) { 315 + void loadLikedState(); 316 + } 317 + void loadComments(); 318 } 319 }); 320 321 let shareUrl = $state('');
+30 -11
frontend/src/routes/u/[handle]/+page.svelte
··· 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 import { fade } from 'svelte/transition'; 4 import { API_URL } from '$lib/config'; 5 import { browser } from '$app/environment'; ··· 55 56 // public playlists for collections section 57 let publicPlaylists = $state<Playlist[]>([]); 58 59 async function handleLogout() { 60 await auth.logout(); ··· 116 } 117 } 118 119 - onMount(() => { 120 - // load analytics in background without blocking page render 121 - loadAnalytics(); 122 - primeLikesFromCache(); 123 - // immediately hydrate tracks client-side for liked state 124 - void hydrateTracksWithLikes(); 125 - // load liked tracks count if artist has show_liked_on_profile enabled 126 - void loadLikedTracksCount(); 127 - // load public playlists for collections section 128 - void loadPublicPlaylists(); 129 }); 130 131 async function hydrateTracksWithLikes() {
··· 1 <script lang="ts"> 2 import { fade } from 'svelte/transition'; 3 import { API_URL } from '$lib/config'; 4 import { browser } from '$app/environment'; ··· 54 55 // public playlists for collections section 56 let publicPlaylists = $state<Playlist[]>([]); 57 + 58 + // track which artist we've loaded data for to detect navigation 59 + let loadedForDid = $state<string | null>(null); 60 61 async function handleLogout() { 62 await auth.logout(); ··· 118 } 119 } 120 121 + // reload data when navigating between artist pages 122 + // watch data.artist?.did (from server) not artist?.did (local derived) 123 + $effect(() => { 124 + const currentDid = data.artist?.did; 125 + if (!currentDid || !browser) return; 126 + 127 + // check if we navigated to a different artist 128 + if (loadedForDid !== currentDid) { 129 + // reset state for new artist 130 + analytics = null; 131 + tracksHydrated = false; 132 + likedTracksCount = null; 133 + publicPlaylists = []; 134 + 135 + // sync tracks from server data 136 + tracks = data.tracks ?? []; 137 + 138 + // mark as loaded for this artist 139 + loadedForDid = currentDid; 140 + 141 + // load fresh data 142 + loadAnalytics(); 143 + primeLikesFromCache(); 144 + void hydrateTracksWithLikes(); 145 + void loadLikedTracksCount(); 146 + void loadPublicPlaylists(); 147 + } 148 }); 149 150 async function hydrateTracksWithLikes() {