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 1 """Read-only track listing endpoints.""" 2 2 3 3 import asyncio 4 + from datetime import datetime 4 5 from typing import Annotated 5 6 6 7 import logfire 7 - from fastapi import Depends 8 + from fastapi import Depends, HTTPException 8 9 from pydantic import BaseModel 9 10 from sqlalchemy import select 10 11 from sqlalchemy.ext.asyncio import AsyncSession ··· 12 13 13 14 from backend._internal import Session as AuthSession 14 15 from backend._internal import get_optional_session, require_auth 16 + from backend.config import settings 15 17 from backend.models import ( 16 18 Artist, 17 19 Tag, ··· 33 35 from .router import router 34 36 35 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 + 36 46 @router.get("/") 37 47 async def list_tracks( 38 48 db: Annotated[AsyncSession, Depends(get_db)], 39 49 artist_did: str | None = None, 40 50 filter_hidden_tags: bool | None = None, 51 + cursor: str | None = None, 52 + limit: int | None = None, 41 53 session: AuthSession | None = Depends(get_optional_session), 42 - ) -> dict: 43 - """List all tracks, optionally filtered by artist DID. 54 + ) -> TracksListResponse: 55 + """List tracks with cursor-based pagination. 44 56 45 57 Args: 46 58 artist_did: Filter to tracks by this artist only. ··· 49 61 don't filter on artist pages) 50 62 - True: always filter hidden tags 51 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). 52 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)) 53 72 from atproto_identity.did.resolver import AsyncDidResolver 54 73 55 74 # get authenticated user's liked tracks and preferences ··· 98 117 ) 99 118 stmt = stmt.where(Track.id.not_in(hidden_track_ids_subq)) 100 119 101 - stmt = stmt.order_by(Track.created_at.desc()) 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) 102 130 result = await db.execute(stmt) 103 - tracks = result.scalars().all() 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 104 139 105 140 # batch fetch like, comment counts, copyright info, and tags for all tracks 106 141 track_ids = [track.id for track in tracks] ··· 213 248 ] 214 249 ) 215 250 216 - return {"tracks": track_responses} 251 + return TracksListResponse( 252 + tracks=list(track_responses), 253 + next_cursor=next_cursor, 254 + has_more=has_more, 255 + ) 217 256 218 257 219 258 @router.get("/me")
+4
backend/src/backend/config.py
··· 119 119 default="plyr", 120 120 description="Prefix used for browser BroadcastChannel identifiers", 121 121 ) 122 + default_page_size: int = Field( 123 + default=50, 124 + description="Default page size for paginated endpoints", 125 + ) 122 126 123 127 124 128 class FrontendSettings(AppSettingsSection):
+82 -20
frontend/src/lib/tracks.svelte.ts
··· 1 1 import { API_URL } from './config'; 2 2 import type { Track } from './types'; 3 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 + 4 16 // load cached tracks from localStorage (no time check - trust invalidate() calls) 5 - function loadCachedTracks(): Track[] { 6 - if (typeof window === 'undefined') return []; 17 + function loadCachedTracks(): CachedTracksData { 18 + if (typeof window === 'undefined') { 19 + return { tracks: [], nextCursor: null, hasMore: true }; 20 + } 7 21 try { 8 22 const cached = localStorage.getItem('tracks_cache'); 9 23 if (cached) { 10 - const { tracks } = JSON.parse(cached); 24 + const data = JSON.parse(cached); 11 25 // check if cache has the new is_liked field, if not invalidate 12 - if (tracks && tracks.length > 0 && !('is_liked' in tracks[0])) { 26 + if (data.tracks && data.tracks.length > 0 && !('is_liked' in data.tracks[0])) { 13 27 localStorage.removeItem('tracks_cache'); 14 - return []; 28 + return { tracks: [], nextCursor: null, hasMore: true }; 15 29 } 16 - return tracks; 30 + return { 31 + tracks: data.tracks || [], 32 + nextCursor: data.nextCursor ?? null, 33 + hasMore: data.hasMore ?? true 34 + }; 17 35 } 18 36 } catch (e) { 19 37 console.warn('failed to load cached tracks:', e); 20 38 } 21 - return []; 39 + return { tracks: [], nextCursor: null, hasMore: true }; 22 40 } 23 41 24 42 // global tracks cache using Svelte 5 runes 25 43 class TracksCache { 26 - tracks = $state<Track[]>(loadCachedTracks()); 44 + tracks = $state<Track[]>(loadCachedTracks().tracks); 27 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 + } 28 66 29 67 async fetch(force = false): Promise<void> { 30 68 // always fetch in background to check for updates ··· 38 76 const response = await fetch(`${API_URL}/tracks/`, { 39 77 credentials: 'include' 40 78 }); 41 - const data = await response.json(); 79 + const data: TracksApiResponse = await response.json(); 42 80 this.tracks = data.tracks; 81 + this.nextCursor = data.next_cursor; 82 + this.hasMore = data.has_more; 43 83 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 - } 84 + this.persistToStorage(); 54 85 } catch (e) { 55 86 console.error('failed to fetch tracks:', e); 56 87 } finally { ··· 58 89 } 59 90 } 60 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 + 61 121 invalidate(): void { 62 - // clear cache - next fetch will get fresh data 122 + // clear cache and reset pagination state - next fetch will get fresh data 63 123 if (typeof window !== 'undefined') { 64 124 localStorage.removeItem('tracks_cache'); 65 125 } 126 + this.nextCursor = null; 127 + this.hasMore = true; 66 128 } 67 129 } 68 130
+43
frontend/src/routes/+page.svelte
··· 14 14 // use cached tracks 15 15 let tracks = $derived(tracksCache.tracks); 16 16 let loadingTracks = $derived(tracksCache.loading); 17 + let loadingMore = $derived(tracksCache.loadingMore); 18 + let hasMore = $derived(tracksCache.hasMore); 17 19 let hasTracks = $derived(tracks.length > 0); 18 20 let initialLoad = $state(true); 19 21 ··· 23 25 // track which track ID we've already auto-played to prevent infinite loops 24 26 let autoPlayedTrackId = $state<string | null>(null); 25 27 28 + // infinite scroll sentinel element 29 + let sentinelElement = $state<HTMLDivElement | null>(null); 30 + 26 31 onMount(async () => { 27 32 // fetch tracks from cache (will use cached data if recent) 28 33 await tracksCache.fetch(); 29 34 initialLoad = false; 30 35 }); 31 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 + 32 60 // reactive effect to auto-play track from URL query param 33 61 $effect(() => { 34 62 const trackId = $page.url.searchParams.get('track'); ··· 112 140 /> 113 141 {/each} 114 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} 115 151 {/if} 116 152 </section> 117 153 </main> ··· 176 212 display: flex; 177 213 flex-direction: column; 178 214 gap: 0.5rem; 215 + } 216 + 217 + .scroll-sentinel { 218 + display: flex; 219 + justify-content: center; 220 + padding: 2rem 0; 221 + min-height: 60px; 179 222 } 180 223 181 224 @media (max-width: 768px) {