fix: artist page track pagination and mobile album card overflow (#615)

- pass hasMoreTracks and nextCursor from API response to frontend
- add "load more tracks" button when there are more tracks to load
- display total track count in section header (from analytics)
- fix album cards running off screen on mobile:
- smaller cover images (56px vs 72px)
- tighter padding and gaps
- smaller text sizes
- ensure overflow:hidden on cards

closes pyxorium.com visibility issue (252 tracks, only 50 shown)

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

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

authored by zzstoatzz.io Claude and committed by GitHub 711b4ab7 a9a479e1

Changed files
+129 -21
frontend
src
routes
+7 -1
frontend/src/routes/u/[handle]/+page.server.ts
··· 17 17 // fetch artist's tracks server-side (no cookie available on frontend host) 18 18 const tracksResponse = await fetch(`${API_URL}/tracks/?artist_did=${artist.did}`); 19 19 let tracks: Track[] = []; 20 + let hasMoreTracks = false; 21 + let nextCursor: string | null = null; 20 22 21 23 if (tracksResponse.ok) { 22 24 const data = await tracksResponse.json(); 23 25 tracks = data.tracks || []; 26 + hasMoreTracks = data.has_more || false; 27 + nextCursor = data.next_cursor || null; 24 28 } 25 29 26 30 const albumsResponse = await fetch(`${API_URL}/albums/${params.handle}`); ··· 34 38 return { 35 39 artist, 36 40 tracks, 37 - albums 41 + albums, 42 + hasMoreTracks, 43 + nextCursor 38 44 }; 39 45 } catch (e) { 40 46 console.error('failed to load artist:', e);
+122 -20
frontend/src/routes/u/[handle]/+page.svelte
··· 29 29 const artist = $derived(data.artist); 30 30 let tracks = $state(data.tracks ?? []); 31 31 const albums = $derived(data.albums ?? []); 32 + let hasMoreTracks = $state(data.hasMoreTracks ?? false); 33 + let nextCursor = $state<string | null>(data.nextCursor ?? null); 34 + let loadingMoreTracks = $state(false); 32 35 let shareUrl = $state(''); 33 36 34 37 // compute support URL - handle 'atprotofans' magic value ··· 141 144 likedTracksCount = null; 142 145 publicPlaylists = []; 143 146 144 - // sync tracks from server data 147 + // sync tracks and pagination from server data 145 148 tracks = data.tracks ?? []; 149 + hasMoreTracks = data.hasMoreTracks ?? false; 150 + nextCursor = data.nextCursor ?? null; 146 151 147 152 // mark as loaded for this artist 148 153 loadedForDid = currentDid; ··· 155 160 void loadPublicPlaylists(); 156 161 } 157 162 }); 163 + 164 + async function loadMoreTracks() { 165 + if (!artist?.did || !nextCursor || loadingMoreTracks) return; 166 + 167 + loadingMoreTracks = true; 168 + try { 169 + const response = await fetch( 170 + `${API_URL}/tracks/?artist_did=${artist.did}&cursor=${encodeURIComponent(nextCursor)}` 171 + ); 172 + if (response.ok) { 173 + const data = await response.json(); 174 + const newTracks = data.tracks || []; 175 + 176 + // hydrate with liked status if authenticated 177 + if (auth.isAuthenticated) { 178 + const likedTracks = await fetchLikedTracks(); 179 + const likedIds = new Set(likedTracks.map(track => track.id)); 180 + for (const track of newTracks) { 181 + track.is_liked = likedIds.has(track.id); 182 + } 183 + } 184 + 185 + tracks = [...tracks, ...newTracks]; 186 + hasMoreTracks = data.has_more || false; 187 + nextCursor = data.next_cursor || null; 188 + } 189 + } catch (_e) { 190 + console.error('failed to load more tracks:', _e); 191 + } finally { 192 + loadingMoreTracks = false; 193 + } 194 + } 158 195 159 196 async function hydrateTracksWithLikes() { 160 197 if (!browser || tracksHydrated) return; ··· 355 392 </section> 356 393 357 394 <section class="tracks"> 358 - <h2> 359 - tracks 360 - {#if tracksLoading} 361 - <span class="tracks-loading">updating…</span> 395 + <div class="section-header"> 396 + <h2> 397 + tracks 398 + {#if tracksLoading} 399 + <span class="tracks-loading">updating…</span> 400 + {/if} 401 + </h2> 402 + {#if analytics?.total_items} 403 + <span>{analytics.total_items} {analytics.total_items === 1 ? 'track' : 'tracks'}</span> 362 404 {/if} 363 - </h2> 405 + </div> 364 406 {#if tracks.length === 0} 365 407 <div class="empty-state"> 366 408 <p class="empty-message">no tracks yet</p> ··· 378 420 </div> 379 421 {:else} 380 422 <div class="track-list"> 381 - {#each tracks as track, i} 382 - <TrackItem 383 - {track} 384 - index={i} 385 - isPlaying={player.currentTrack?.id === track.id} 386 - onPlay={(t) => queue.playNow(t)} 387 - isAuthenticated={auth.isAuthenticated} 388 - hideArtist={true} 389 - /> 390 - {/each} 391 - </div> 392 - {/if} 423 + {#each tracks as track, i} 424 + <TrackItem 425 + {track} 426 + index={i} 427 + isPlaying={player.currentTrack?.id === track.id} 428 + onPlay={(t) => queue.playNow(t)} 429 + isAuthenticated={auth.isAuthenticated} 430 + hideArtist={true} 431 + /> 432 + {/each} 433 + </div> 434 + {#if hasMoreTracks} 435 + <button 436 + class="load-more-btn" 437 + onclick={loadMoreTracks} 438 + disabled={loadingMoreTracks} 439 + > 440 + {#if loadingMoreTracks} 441 + loading… 442 + {:else} 443 + load more tracks 444 + {/if} 445 + </button> 446 + {/if} 447 + {/if} 393 448 </section> 394 449 395 450 {#if albums.length > 0} ··· 650 705 color: inherit; 651 706 text-decoration: none; 652 707 transition: transform 0.15s ease, border-color 0.15s ease; 708 + overflow: hidden; 709 + max-width: 100%; 653 710 } 654 711 655 712 .album-card:hover { ··· 835 892 margin-top: 2rem; 836 893 } 837 894 838 - .tracks h2 { 839 - margin-bottom: 1.5rem; 895 + .tracks .section-header h2 { 896 + margin: 0; 840 897 color: var(--text-primary); 841 898 font-size: 1.8rem; 842 899 } 843 900 901 + .load-more-btn { 902 + display: block; 903 + width: 100%; 904 + margin-top: 1rem; 905 + padding: 0.75rem 1.5rem; 906 + background: var(--bg-secondary); 907 + border: 1px solid var(--border-subtle); 908 + border-radius: 8px; 909 + color: var(--text-secondary); 910 + font-size: 0.95rem; 911 + cursor: pointer; 912 + transition: all 0.2s ease; 913 + } 914 + 915 + .load-more-btn:hover:not(:disabled) { 916 + background: var(--bg-tertiary); 917 + border-color: var(--accent); 918 + color: var(--accent); 919 + } 920 + 921 + .load-more-btn:disabled { 922 + opacity: 0.6; 923 + cursor: not-allowed; 924 + } 925 + 844 926 .tracks-loading { 845 927 margin-left: 0.75rem; 846 928 font-size: 0.95rem; ··· 960 1042 961 1043 .album-grid { 962 1044 grid-template-columns: 1fr; 1045 + } 1046 + 1047 + .album-card { 1048 + padding: 0.75rem; 1049 + gap: 0.75rem; 1050 + } 1051 + 1052 + .album-cover-wrapper { 1053 + width: 56px; 1054 + height: 56px; 1055 + border-radius: 4px; 1056 + } 1057 + 1058 + .album-card-meta h3 { 1059 + font-size: 0.95rem; 1060 + margin-bottom: 0.25rem; 1061 + } 1062 + 1063 + .album-card-meta p { 1064 + font-size: 0.8rem; 963 1065 } 964 1066 } 965 1067