feat: add pagination and infinite scroll to tracks list (#554)

* feat: add pagination and infinite scroll to tracks list

backend:
- add cursor-based pagination to /tracks/ endpoint
- use created_at timestamp as cursor for stable pagination
- return next_cursor and has_more fields in response
- default page size of 50, max 100

frontend:
- update TracksCache to support fetchMore() for pagination
- persist pagination state (nextCursor, hasMore) to localStorage
- implement infinite scroll on homepage using IntersectionObserver
- add scroll sentinel element with loading indicator
- trigger fetch 200px before reaching bottom for smooth UX

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

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

* fix: address PR feedback

- move default page size to settings.app.default_page_size
- raise 400 error on invalid cursor format instead of ignoring
- use walrus operator for has_more logic

🤖 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 9facaf5e 5c8a05d5

Changed files
+174 -26
backend
src
backend
api
tracks
frontend
+45 -6
backend/src/backend/api/tracks/listing.py
··· 1 """Read-only track listing endpoints.""" 2 3 import asyncio 4 from typing import Annotated 5 6 import logfire 7 - from fastapi import Depends 8 from pydantic import BaseModel 9 from sqlalchemy import select 10 from sqlalchemy.ext.asyncio import AsyncSession ··· 12 13 from backend._internal import Session as AuthSession 14 from backend._internal import get_optional_session, require_auth 15 from backend.models import ( 16 Artist, 17 Tag, ··· 33 from .router import router 34 35 36 @router.get("/") 37 async def list_tracks( 38 db: Annotated[AsyncSession, Depends(get_db)], 39 artist_did: str | None = None, 40 filter_hidden_tags: bool | None = None, 41 session: AuthSession | None = Depends(get_optional_session), 42 - ) -> dict: 43 - """List all tracks, optionally filtered by artist DID. 44 45 Args: 46 artist_did: Filter to tracks by this artist only. ··· 49 don't filter on artist pages) 50 - True: always filter hidden tags 51 - False: never filter hidden tags 52 """ 53 from atproto_identity.did.resolver import AsyncDidResolver 54 55 # get authenticated user's liked tracks and preferences ··· 98 ) 99 stmt = stmt.where(Track.id.not_in(hidden_track_ids_subq)) 100 101 - stmt = stmt.order_by(Track.created_at.desc()) 102 result = await db.execute(stmt) 103 - tracks = result.scalars().all() 104 105 # batch fetch like, comment counts, copyright info, and tags for all tracks 106 track_ids = [track.id for track in tracks] ··· 213 ] 214 ) 215 216 - return {"tracks": track_responses} 217 218 219 @router.get("/me")
··· 1 """Read-only track listing endpoints.""" 2 3 import asyncio 4 + from datetime import datetime 5 from typing import Annotated 6 7 import logfire 8 + from fastapi import Depends, HTTPException 9 from pydantic import BaseModel 10 from sqlalchemy import select 11 from sqlalchemy.ext.asyncio import AsyncSession ··· 13 14 from backend._internal import Session as AuthSession 15 from backend._internal import get_optional_session, require_auth 16 + from backend.config import settings 17 from backend.models import ( 18 Artist, 19 Tag, ··· 35 from .router import router 36 37 38 + class TracksListResponse(BaseModel): 39 + """Response for paginated track listing.""" 40 + 41 + tracks: list[TrackResponse] 42 + next_cursor: str | None = None 43 + has_more: bool = False 44 + 45 + 46 @router.get("/") 47 async def list_tracks( 48 db: Annotated[AsyncSession, Depends(get_db)], 49 artist_did: str | None = None, 50 filter_hidden_tags: bool | None = None, 51 + cursor: str | None = None, 52 + limit: int | None = None, 53 session: AuthSession | None = Depends(get_optional_session), 54 + ) -> TracksListResponse: 55 + """List tracks with cursor-based pagination. 56 57 Args: 58 artist_did: Filter to tracks by this artist only. ··· 61 don't filter on artist pages) 62 - True: always filter hidden tags 63 - False: never filter hidden tags 64 + cursor: ISO timestamp cursor from previous response's next_cursor. 65 + Pass this to get the next page of results. 66 + limit: Maximum number of tracks to return (default from settings, max 100). 67 """ 68 + # use settings default if not provided, clamp to reasonable bounds 69 + if limit is None: 70 + limit = settings.app.default_page_size 71 + limit = max(1, min(limit, 100)) 72 from atproto_identity.did.resolver import AsyncDidResolver 73 74 # get authenticated user's liked tracks and preferences ··· 117 ) 118 stmt = stmt.where(Track.id.not_in(hidden_track_ids_subq)) 119 120 + # apply cursor-based pagination (tracks older than cursor timestamp) 121 + if cursor: 122 + try: 123 + cursor_time = datetime.fromisoformat(cursor) 124 + stmt = stmt.where(Track.created_at < cursor_time) 125 + except ValueError as e: 126 + raise HTTPException(status_code=400, detail="invalid cursor format") from e 127 + 128 + # order by created_at desc and fetch one extra to check if there's more 129 + stmt = stmt.order_by(Track.created_at.desc()).limit(limit + 1) 130 result = await db.execute(stmt) 131 + tracks = list(result.scalars().all()) 132 + 133 + # check if there are more results and trim to requested limit 134 + if has_more := len(tracks) > limit: 135 + tracks = tracks[:limit] 136 + 137 + # generate next cursor from the last track's created_at 138 + next_cursor = tracks[-1].created_at.isoformat() if has_more and tracks else None 139 140 # batch fetch like, comment counts, copyright info, and tags for all tracks 141 track_ids = [track.id for track in tracks] ··· 248 ] 249 ) 250 251 + return TracksListResponse( 252 + tracks=list(track_responses), 253 + next_cursor=next_cursor, 254 + has_more=has_more, 255 + ) 256 257 258 @router.get("/me")
+4
backend/src/backend/config.py
··· 119 default="plyr", 120 description="Prefix used for browser BroadcastChannel identifiers", 121 ) 122 123 124 class FrontendSettings(AppSettingsSection):
··· 119 default="plyr", 120 description="Prefix used for browser BroadcastChannel identifiers", 121 ) 122 + default_page_size: int = Field( 123 + default=50, 124 + description="Default page size for paginated endpoints", 125 + ) 126 127 128 class FrontendSettings(AppSettingsSection):
+82 -20
frontend/src/lib/tracks.svelte.ts
··· 1 import { API_URL } from './config'; 2 import type { Track } from './types'; 3 4 // load cached tracks from localStorage (no time check - trust invalidate() calls) 5 - function loadCachedTracks(): Track[] { 6 - if (typeof window === 'undefined') return []; 7 try { 8 const cached = localStorage.getItem('tracks_cache'); 9 if (cached) { 10 - const { tracks } = JSON.parse(cached); 11 // check if cache has the new is_liked field, if not invalidate 12 - if (tracks && tracks.length > 0 && !('is_liked' in tracks[0])) { 13 localStorage.removeItem('tracks_cache'); 14 - return []; 15 } 16 - return tracks; 17 } 18 } catch (e) { 19 console.warn('failed to load cached tracks:', e); 20 } 21 - return []; 22 } 23 24 // global tracks cache using Svelte 5 runes 25 class TracksCache { 26 - tracks = $state<Track[]>(loadCachedTracks()); 27 loading = $state(false); 28 29 async fetch(force = false): Promise<void> { 30 // always fetch in background to check for updates ··· 38 const response = await fetch(`${API_URL}/tracks/`, { 39 credentials: 'include' 40 }); 41 - const data = await response.json(); 42 this.tracks = data.tracks; 43 44 - // persist to localStorage 45 - if (typeof window !== 'undefined') { 46 - try { 47 - localStorage.setItem('tracks_cache', JSON.stringify({ 48 - tracks: this.tracks 49 - })); 50 - } catch (e) { 51 - console.warn('failed to cache tracks:', e); 52 - } 53 - } 54 } catch (e) { 55 console.error('failed to fetch tracks:', e); 56 } finally { ··· 58 } 59 } 60 61 invalidate(): void { 62 - // clear cache - next fetch will get fresh data 63 if (typeof window !== 'undefined') { 64 localStorage.removeItem('tracks_cache'); 65 } 66 } 67 } 68
··· 1 import { API_URL } from './config'; 2 import type { Track } from './types'; 3 4 + interface TracksApiResponse { 5 + tracks: Track[]; 6 + next_cursor: string | null; 7 + has_more: boolean; 8 + } 9 + 10 + interface CachedTracksData { 11 + tracks: Track[]; 12 + nextCursor: string | null; 13 + hasMore: boolean; 14 + } 15 + 16 // load cached tracks from localStorage (no time check - trust invalidate() calls) 17 + function loadCachedTracks(): CachedTracksData { 18 + if (typeof window === 'undefined') { 19 + return { tracks: [], nextCursor: null, hasMore: true }; 20 + } 21 try { 22 const cached = localStorage.getItem('tracks_cache'); 23 if (cached) { 24 + const data = JSON.parse(cached); 25 // check if cache has the new is_liked field, if not invalidate 26 + if (data.tracks && data.tracks.length > 0 && !('is_liked' in data.tracks[0])) { 27 localStorage.removeItem('tracks_cache'); 28 + return { tracks: [], nextCursor: null, hasMore: true }; 29 } 30 + return { 31 + tracks: data.tracks || [], 32 + nextCursor: data.nextCursor ?? null, 33 + hasMore: data.hasMore ?? true 34 + }; 35 } 36 } catch (e) { 37 console.warn('failed to load cached tracks:', e); 38 } 39 + return { tracks: [], nextCursor: null, hasMore: true }; 40 } 41 42 // global tracks cache using Svelte 5 runes 43 class TracksCache { 44 + tracks = $state<Track[]>(loadCachedTracks().tracks); 45 loading = $state(false); 46 + loadingMore = $state(false); 47 + nextCursor = $state<string | null>(loadCachedTracks().nextCursor); 48 + hasMore = $state(loadCachedTracks().hasMore); 49 + 50 + private persistToStorage(): void { 51 + if (typeof window !== 'undefined') { 52 + try { 53 + localStorage.setItem( 54 + 'tracks_cache', 55 + JSON.stringify({ 56 + tracks: this.tracks, 57 + nextCursor: this.nextCursor, 58 + hasMore: this.hasMore 59 + }) 60 + ); 61 + } catch (e) { 62 + console.warn('failed to cache tracks:', e); 63 + } 64 + } 65 + } 66 67 async fetch(force = false): Promise<void> { 68 // always fetch in background to check for updates ··· 76 const response = await fetch(`${API_URL}/tracks/`, { 77 credentials: 'include' 78 }); 79 + const data: TracksApiResponse = await response.json(); 80 this.tracks = data.tracks; 81 + this.nextCursor = data.next_cursor; 82 + this.hasMore = data.has_more; 83 84 + this.persistToStorage(); 85 } catch (e) { 86 console.error('failed to fetch tracks:', e); 87 } finally { ··· 89 } 90 } 91 92 + async fetchMore(): Promise<void> { 93 + // don't fetch if already loading or no more results 94 + if (this.loadingMore || this.loading || !this.hasMore || !this.nextCursor) { 95 + return; 96 + } 97 + 98 + this.loadingMore = true; 99 + try { 100 + const url = new URL(`${API_URL}/tracks/`); 101 + url.searchParams.set('cursor', this.nextCursor); 102 + 103 + const response = await fetch(url.toString(), { 104 + credentials: 'include' 105 + }); 106 + const data: TracksApiResponse = await response.json(); 107 + 108 + // append new tracks to existing list 109 + this.tracks = [...this.tracks, ...data.tracks]; 110 + this.nextCursor = data.next_cursor; 111 + this.hasMore = data.has_more; 112 + 113 + this.persistToStorage(); 114 + } catch (e) { 115 + console.error('failed to fetch more tracks:', e); 116 + } finally { 117 + this.loadingMore = false; 118 + } 119 + } 120 + 121 invalidate(): void { 122 + // clear cache and reset pagination state - next fetch will get fresh data 123 if (typeof window !== 'undefined') { 124 localStorage.removeItem('tracks_cache'); 125 } 126 + this.nextCursor = null; 127 + this.hasMore = true; 128 } 129 } 130
+43
frontend/src/routes/+page.svelte
··· 14 // use cached tracks 15 let tracks = $derived(tracksCache.tracks); 16 let loadingTracks = $derived(tracksCache.loading); 17 let hasTracks = $derived(tracks.length > 0); 18 let initialLoad = $state(true); 19 ··· 23 // track which track ID we've already auto-played to prevent infinite loops 24 let autoPlayedTrackId = $state<string | null>(null); 25 26 onMount(async () => { 27 // fetch tracks from cache (will use cached data if recent) 28 await tracksCache.fetch(); 29 initialLoad = false; 30 }); 31 32 // reactive effect to auto-play track from URL query param 33 $effect(() => { 34 const trackId = $page.url.searchParams.get('track'); ··· 112 /> 113 {/each} 114 </div> 115 {/if} 116 </section> 117 </main> ··· 176 display: flex; 177 flex-direction: column; 178 gap: 0.5rem; 179 } 180 181 @media (max-width: 768px) {
··· 14 // use cached tracks 15 let tracks = $derived(tracksCache.tracks); 16 let loadingTracks = $derived(tracksCache.loading); 17 + let loadingMore = $derived(tracksCache.loadingMore); 18 + let hasMore = $derived(tracksCache.hasMore); 19 let hasTracks = $derived(tracks.length > 0); 20 let initialLoad = $state(true); 21 ··· 25 // track which track ID we've already auto-played to prevent infinite loops 26 let autoPlayedTrackId = $state<string | null>(null); 27 28 + // infinite scroll sentinel element 29 + let sentinelElement = $state<HTMLDivElement | null>(null); 30 + 31 onMount(async () => { 32 // fetch tracks from cache (will use cached data if recent) 33 await tracksCache.fetch(); 34 initialLoad = false; 35 }); 36 37 + // set up IntersectionObserver for infinite scroll 38 + $effect(() => { 39 + if (!sentinelElement) return; 40 + 41 + const observer = new IntersectionObserver( 42 + (entries) => { 43 + const entry = entries[0]; 44 + if (entry.isIntersecting && hasMore && !loadingMore && !loadingTracks) { 45 + tracksCache.fetchMore(); 46 + } 47 + }, 48 + { 49 + rootMargin: '200px' // trigger 200px before reaching the end 50 + } 51 + ); 52 + 53 + observer.observe(sentinelElement); 54 + 55 + return () => { 56 + observer.disconnect(); 57 + }; 58 + }); 59 + 60 // reactive effect to auto-play track from URL query param 61 $effect(() => { 62 const trackId = $page.url.searchParams.get('track'); ··· 140 /> 141 {/each} 142 </div> 143 + <!-- infinite scroll sentinel --> 144 + {#if hasMore} 145 + <div bind:this={sentinelElement} class="scroll-sentinel"> 146 + {#if loadingMore} 147 + <WaveLoading size="sm" message="loading more..." /> 148 + {/if} 149 + </div> 150 + {/if} 151 {/if} 152 </section> 153 </main> ··· 212 display: flex; 213 flex-direction: column; 214 gap: 0.5rem; 215 + } 216 + 217 + .scroll-sentinel { 218 + display: flex; 219 + justify-content: center; 220 + padding: 2rem 0; 221 + min-height: 60px; 222 } 223 224 @media (max-width: 768px) {