grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

feat: paginate favorites tab with load more button

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

+55 -10
+31
app/lib/components/organisms/GalleryGrid.svelte
··· 11 11 items, 12 12 loading = false, 13 13 emptyText = 'No galleries yet.', 14 + hasMore = false, 15 + loadingMore = false, 16 + onLoadMore, 14 17 }: { 15 18 items: GalleryView[] 16 19 loading?: boolean 17 20 emptyText?: string 21 + hasMore?: boolean 22 + loadingMore?: boolean 23 + onLoadMore?: () => void 18 24 } = $props() 19 25 20 26 function thumb(gallery: GalleryView): string | undefined { ··· 62 68 </a> 63 69 {/each} 64 70 </div> 71 + {#if hasMore} 72 + <div class="load-more"> 73 + <button class="load-more-btn" onclick={() => onLoadMore?.()} disabled={loadingMore}> 74 + {loadingMore ? 'Loading\u2026' : 'Load more'} 75 + </button> 76 + </div> 77 + {/if} 65 78 {/if} 66 79 67 80 <style> ··· 121 134 font-size: 11px; 122 135 font-weight: 500; 123 136 } 137 + .load-more { padding: 16px; text-align: center; } 138 + .load-more-btn { 139 + padding: 10px 24px; 140 + border-radius: 20px; 141 + background: var(--bg-elevated); 142 + border: 1px solid var(--border); 143 + color: var(--text-secondary); 144 + font-family: var(--font-body); 145 + font-size: 14px; 146 + font-weight: 500; 147 + cursor: pointer; 148 + transition: all 0.12s; 149 + } 150 + .load-more-btn:hover:not(:disabled) { 151 + background: var(--bg-hover); 152 + color: var(--text-primary); 153 + } 154 + .load-more-btn:disabled { opacity: 0.5; cursor: not-allowed; } 124 155 .empty-state { 125 156 padding: 48px; 126 157 text-align: center;
+11 -5
app/lib/queries.ts
··· 1 - import { queryOptions } from "@tanstack/svelte-query"; 1 + import { infiniteQueryOptions, queryOptions } from "@tanstack/svelte-query"; 2 2 import { callXrpc } from "$hatk/client"; 3 3 4 4 type Fetch = typeof globalThis.fetch; ··· 58 58 59 59 // ─── Favorites ────────────────────────────────────────────────────── 60 60 61 - export const actorFavoritesQuery = (did: string, f?: Fetch) => 62 - queryOptions({ 61 + export const actorFavoritesInfiniteQuery = (did: string, f?: Fetch) => 62 + infiniteQueryOptions({ 63 63 queryKey: ["actorFavorites", did], 64 - queryFn: () => 65 - callXrpc("social.grain.unspecced.getActorFavorites", { actor: did }, f), 64 + initialPageParam: undefined as string | undefined, 65 + queryFn: ({ pageParam }) => 66 + callXrpc( 67 + "social.grain.unspecced.getActorFavorites", 68 + { actor: did, limit: 30, ...(pageParam ? { cursor: pageParam } : {}) }, 69 + f, 70 + ), 71 + getNextPageParam: (lastPage) => lastPage?.cursor, 66 72 staleTime: 60_000, 67 73 }); 68 74
+13 -5
app/routes/profile/[did]/+page.svelte
··· 8 8 import FollowButton from '$lib/components/molecules/FollowButton.svelte' 9 9 import RichText from '$lib/components/atoms/RichText.svelte' 10 10 import { ArrowUpRight, Grid3x3, Heart, Clock } from 'lucide-svelte' 11 - import { createQuery } from '@tanstack/svelte-query' 12 - import { actorProfileQuery, actorFeedQuery, actorFavoritesQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' 11 + import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query' 12 + import { actorProfileQuery, actorFeedQuery, actorFavoritesInfiniteQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' 13 13 import { viewer as viewerStore } from '$lib/stores' 14 14 import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 15 15 import StoryArchive from '$lib/components/molecules/StoryArchive.svelte' ··· 26 26 27 27 const profile = createQuery(() => actorProfileQuery(did, viewerDid)) 28 28 const feed = createQuery(() => actorFeedQuery(did)) 29 - const favorites = createQuery(() => ({ 30 - ...actorFavoritesQuery(did), 29 + const favorites = createInfiniteQuery(() => ({ 30 + ...actorFavoritesInfiniteQuery(did), 31 31 enabled: isOwnProfile, 32 32 })) 33 + const favoriteItems = $derived(favorites.data?.pages.flatMap((p) => p.items ?? []) ?? []) 33 34 const stories = createQuery(() => storiesQuery(did)) 34 35 const hasStory = $derived((stories.data?.length ?? 0) > 0) 35 36 const knownFollowers = createQuery(() => ({ ··· 147 148 {#if viewMode === 'stories' && isOwnProfile} 148 149 <StoryArchive {did} /> 149 150 {:else if viewMode === 'favorites' && isOwnProfile} 150 - <GalleryGrid items={favorites.data?.items ?? []} loading={favorites.isLoading} emptyText="No favorites yet." /> 151 + <GalleryGrid 152 + items={favoriteItems} 153 + loading={favorites.isLoading} 154 + emptyText="No favorites yet." 155 + hasMore={favorites.hasNextPage} 156 + loadingMore={favorites.isFetchingNextPage} 157 + onLoadMore={() => favorites.fetchNextPage()} 158 + /> 151 159 {:else} 152 160 <GalleryGrid items={feed.data?.items ?? []} loading={feed.isLoading} /> 153 161 {/if}