vod frog, frog with the vods
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

paginate profile page videos with wobbly prev/next buttons

+89 -10
+22 -7
src/lib/api.ts
··· 135 135 }; 136 136 } 137 137 138 - /** Fetch videos by a specific creator DID */ 139 - export async function listVideosByCreator(creatorDid: string): Promise<VideoRecord[]> { 138 + /** Fetch paginated videos by a specific creator DID */ 139 + export async function listVideosByCreator(creatorDid: string, cursor?: string, pageSize = 9): Promise<{ 140 + records: VideoRecord[]; 141 + hasMore: boolean; 142 + endCursor?: string; 143 + }> { 140 144 const query = ` 141 - query GetVideosByCreator($creatorDid: String!) { 142 - placeStreamVideo(first: 50, where: { creator: { eq: $creatorDid } }) { 145 + query GetVideosByCreator($creatorDid: String!, $first: Int, $after: String) { 146 + placeStreamVideo(first: $first, after: $after, where: { creator: { eq: $creatorDid } }) { 143 147 edges { 144 148 node { 145 149 uri ··· 156 160 } 157 161 } 158 162 } 163 + pageInfo { 164 + hasNextPage 165 + endCursor 166 + } 159 167 } 160 168 }`; 161 169 162 170 const res = await fetch(VODSLICE_API, { 163 171 method: 'POST', 164 172 headers: { 'Content-Type': 'application/json' }, 165 - body: JSON.stringify({ query, variables: { creatorDid } }) 173 + body: JSON.stringify({ query, variables: { creatorDid, first: pageSize, after: cursor || null } }) 166 174 }); 167 175 168 176 if (!res.ok) throw new Error(`Failed to fetch creator videos: ${res.status}`); 169 177 const json = await res.json(); 170 - const edges = json.data?.placeStreamVideo?.edges || []; 178 + const data = json.data?.placeStreamVideo; 179 + if (!data) return { records: [], hasMore: false }; 171 180 172 - return edges.map((edge: VodSliceEdge) => { 181 + const records = data.edges.map((edge: VodSliceEdge) => { 173 182 const n = edge.node; 174 183 let livestream: { cid: string; uri: string } | undefined; 175 184 if (typeof n.livestream === 'string') { ··· 191 200 } 192 201 }; 193 202 }); 203 + 204 + return { 205 + records, 206 + hasMore: data.pageInfo.hasNextPage, 207 + endCursor: data.pageInfo.endCursor 208 + }; 194 209 } 195 210 196 211 /** Fetch all videos (for deep-link lookup). Uses pagination internally. */
+67 -3
src/routes/profile/[handle]/+page.svelte
··· 4 4 import { getProfile, listVideosByCreator, getPlaylistUrl, formatDuration, formatDate, type BskyProfile, type VideoRecord } from '$lib/api'; 5 5 import WavyBorder from '$lib/WavyBorder.svelte'; 6 6 import WavyCircle from '$lib/WavyCircle.svelte'; 7 + import WavyButton from '$lib/WavyButton.svelte'; 7 8 import VideoCard from '$lib/VideoCard.svelte'; 8 9 import VideoPlayer from '$lib/VideoPlayer.svelte'; 9 10 import FrogHeader from '$lib/FrogHeader.svelte'; 11 + 12 + const PAGE_SIZE = 9; 10 13 11 14 let profile: BskyProfile | null = $state(null); 12 15 let videos: VideoRecord[] = $state([]); 13 16 let loading = $state(true); 17 + let videosLoading = $state(false); 14 18 let error = $state(''); 15 19 let selectedVideo: VideoRecord | null = $state(null); 20 + 21 + // Pagination 22 + let cursorHistory: (string | undefined)[] = $state([undefined]); 23 + let pageIndex = $state(0); 24 + let hasMore = $state(false); 16 25 17 26 let handle = $derived($page.params.handle); 18 27 28 + async function loadVideos() { 29 + if (!profile?.did) return; 30 + videosLoading = true; 31 + try { 32 + const res = await listVideosByCreator(profile.did, cursorHistory[pageIndex], PAGE_SIZE); 33 + videos = res.records; 34 + hasMore = res.hasMore; 35 + if (res.hasMore && res.endCursor && cursorHistory.length <= pageIndex + 1) { 36 + cursorHistory = [...cursorHistory, res.endCursor]; 37 + } 38 + } catch (e: any) { 39 + error = e.message; 40 + } 41 + videosLoading = false; 42 + } 43 + 44 + function nextPage() { 45 + if (!hasMore || videosLoading) return; 46 + pageIndex++; 47 + loadVideos(); 48 + window.scrollTo({ top: 0, behavior: 'smooth' }); 49 + } 50 + 51 + function prevPage() { 52 + if (pageIndex <= 0 || videosLoading) return; 53 + pageIndex--; 54 + loadVideos(); 55 + window.scrollTo({ top: 0, behavior: 'smooth' }); 56 + } 57 + 19 58 onMount(async () => { 20 59 loading = true; 21 60 error = ''; 22 61 try { 23 62 profile = await getProfile(handle); 24 - // Fetch videos created by this account 25 63 if (profile?.did) { 26 - videos = await listVideosByCreator(profile.did); 64 + await loadVideos(); 27 65 } 28 66 } catch (e: any) { 29 67 error = e.message; ··· 137 175 <VideoCard {video} onSelect={selectVideo} /> 138 176 {/each} 139 177 </section> 140 - {:else} 178 + {:else if !videosLoading} 141 179 <p class="no-videos">no videos found for this creator</p> 180 + {/if} 181 + 182 + {#if videos.length > 0 || pageIndex > 0} 183 + <div class="pagination"> 184 + {#if pageIndex > 0} 185 + <WavyButton seed="prof-prev" disabled={videosLoading} onclick={prevPage}>← previous</WavyButton> 186 + {/if} 187 + <span class="page-num">page {pageIndex + 1}</span> 188 + {#if hasMore} 189 + <WavyButton seed="prof-next" disabled={videosLoading} onclick={nextPage}>next →</WavyButton> 190 + {/if} 191 + </div> 142 192 {/if} 143 193 {/if} 144 194 ··· 332 382 opacity: 0.6; 333 383 text-align: center; 334 384 padding: 40px; 385 + } 386 + 387 + .pagination { 388 + display: flex; 389 + justify-content: center; 390 + align-items: center; 391 + gap: 20px; 392 + padding: 30px; 393 + } 394 + 395 + .page-num { 396 + font-family: 'PicNic', cursive, system-ui; 397 + font-size: 1.1rem; 398 + color: #0A182B; 335 399 } 336 400 337 401 .back-link {