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: show horizontal gallery rails on cameras and locations index pages

Each row lazy-loads its feed via IntersectionObserver, so the page only
fetches galleries for sections approaching the viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+228 -42
+216
app/lib/components/molecules/GallerySectionRow.svelte
··· 1 + <script lang="ts"> 2 + import { createQuery } from '@tanstack/svelte-query' 3 + import { cameraFeedQuery, locationFeedQuery } from '$lib/queries' 4 + import type { GalleryView, PhotoView } from '$hatk/client' 5 + import Avatar from '../atoms/Avatar.svelte' 6 + import { ChevronRight } from 'lucide-svelte' 7 + 8 + type Props = 9 + | { kind: 'camera'; camera: string; href: string } 10 + | { kind: 'location'; h3: string; name: string; href: string } 11 + 12 + let props: Props = $props() 13 + 14 + let sectionEl: HTMLElement | undefined = $state() 15 + let visible = $state(false) 16 + 17 + $effect(() => { 18 + if (!sectionEl) return 19 + const io = new IntersectionObserver( 20 + (entries) => { 21 + if (entries.some((e) => e.isIntersecting)) { 22 + visible = true 23 + io.disconnect() 24 + } 25 + }, 26 + { rootMargin: '400px 0px' }, 27 + ) 28 + io.observe(sectionEl) 29 + return () => io.disconnect() 30 + }) 31 + 32 + const feed = createQuery(() => { 33 + if (props.kind === 'camera') { 34 + return { ...cameraFeedQuery(props.camera, 8), enabled: visible } 35 + } 36 + return { ...locationFeedQuery(props.h3, props.name, 8), enabled: visible } 37 + }) 38 + 39 + const galleries = $derived((feed.data?.items ?? []) as GalleryView[]) 40 + 41 + function firstPhoto(g: GalleryView): PhotoView | undefined { 42 + return (g.items ?? [])[0] as PhotoView | undefined 43 + } 44 + function rkey(uri: string): string { 45 + return uri.split('/').pop() ?? '' 46 + } 47 + </script> 48 + 49 + <section class="section" bind:this={sectionEl}> 50 + <a class="section-head" href={props.href}> 51 + <h2 class="title">{props.kind === 'camera' ? props.camera : props.name}</h2> 52 + <ChevronRight size={18} /> 53 + </a> 54 + 55 + <div class="rail"> 56 + {#if !visible || feed.isPending} 57 + {#each Array(8) as _, i (i)} 58 + <div class="card skeleton"></div> 59 + {/each} 60 + {:else if galleries.length === 0} 61 + <div class="empty">No galleries yet</div> 62 + {:else} 63 + {#each galleries as g (g.uri)} 64 + {@const p = firstPhoto(g)} 65 + {@const author = 66 + g.creator?.displayName || 67 + (g.creator?.handle ? `@${g.creator.handle}` : '')} 68 + {@const linkLabel = 69 + g.title || p?.alt || (author ? `Gallery by ${author}` : 'Gallery')} 70 + <div class="card"> 71 + <a 72 + class="thumb-link" 73 + href="/profile/{g.creator?.did}/gallery/{rkey(g.uri)}" 74 + aria-label={linkLabel} 75 + > 76 + <div class="thumb"> 77 + {#if p} 78 + <img src={p.thumb} alt="" loading="lazy" decoding="async" /> 79 + {/if} 80 + </div> 81 + </a> 82 + <a class="author-row" href="/profile/{g.creator?.did}"> 83 + <Avatar 84 + did={g.creator?.did ?? ''} 85 + src={g.creator?.avatar ?? null} 86 + name={g.creator?.displayName ?? g.creator?.handle ?? null} 87 + size={20} 88 + /> 89 + <span class="author-name">{author}</span> 90 + </a> 91 + </div> 92 + {/each} 93 + {/if} 94 + </div> 95 + </section> 96 + 97 + <style> 98 + .section { 99 + padding: 18px 0; 100 + border-bottom: 1px solid var(--border); 101 + } 102 + .section-head { 103 + display: flex; 104 + align-items: center; 105 + gap: 8px; 106 + padding: 0 16px 18px; 107 + text-decoration: none; 108 + color: inherit; 109 + } 110 + .title { 111 + flex: 1; 112 + min-width: 0; 113 + margin: 0; 114 + font-size: 17px; 115 + font-weight: 700; 116 + letter-spacing: -0.01em; 117 + overflow: hidden; 118 + text-overflow: ellipsis; 119 + white-space: nowrap; 120 + } 121 + .section-head :global(svg) { 122 + color: var(--text-muted); 123 + flex-shrink: 0; 124 + } 125 + .section-head:hover .title { 126 + text-decoration: underline; 127 + } 128 + 129 + .rail { 130 + display: flex; 131 + gap: 10px; 132 + overflow-x: auto; 133 + overflow-y: hidden; 134 + padding: 0 16px; 135 + scroll-snap-type: x proximity; 136 + scrollbar-width: none; 137 + -ms-overflow-style: none; 138 + -webkit-overflow-scrolling: touch; 139 + } 140 + .rail::-webkit-scrollbar { 141 + display: none; 142 + } 143 + 144 + .card { 145 + flex: 0 0 auto; 146 + width: 180px; 147 + scroll-snap-align: start; 148 + } 149 + .card.skeleton { 150 + pointer-events: none; 151 + } 152 + .card.skeleton .thumb { 153 + background: var(--bg-elevated); 154 + animation: pulse 1.2s ease-in-out infinite; 155 + } 156 + .card.skeleton::after { 157 + content: ''; 158 + display: block; 159 + height: 28px; 160 + margin-top: 10px; 161 + } 162 + 163 + .thumb-link { 164 + display: block; 165 + text-decoration: none; 166 + } 167 + .thumb { 168 + position: relative; 169 + width: 100%; 170 + aspect-ratio: 3 / 4; 171 + overflow: hidden; 172 + background: var(--bg-elevated); 173 + } 174 + .thumb img { 175 + width: 100%; 176 + height: 100%; 177 + object-fit: cover; 178 + display: block; 179 + } 180 + 181 + .author-row { 182 + display: flex; 183 + align-items: center; 184 + gap: 8px; 185 + margin-top: 10px; 186 + min-width: 0; 187 + text-decoration: none; 188 + color: inherit; 189 + } 190 + .author-name { 191 + font-size: 13px; 192 + color: var(--text-primary); 193 + overflow: hidden; 194 + text-overflow: ellipsis; 195 + white-space: nowrap; 196 + } 197 + .author-row:hover .author-name { 198 + text-decoration: underline; 199 + } 200 + 201 + .empty { 202 + padding: 40px 4px; 203 + color: var(--text-muted); 204 + font-size: 13px; 205 + } 206 + 207 + @keyframes pulse { 208 + 0%, 209 + 100% { 210 + opacity: 1; 211 + } 212 + 50% { 213 + opacity: 0.55; 214 + } 215 + } 216 + </style>
+6 -20
app/routes/cameras/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import GallerySectionRow from '$lib/components/molecules/GallerySectionRow.svelte' 3 4 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 4 5 import { createQuery } from '@tanstack/svelte-query' 5 6 import { camerasQuery } from '$lib/queries' ··· 17 18 <div class="state">No cameras yet.</div> 18 19 {:else} 19 20 {#each cameras.data as c (c.camera)} 20 - <a class="row" href="/camera/{encodeURIComponent(c.camera)}"> 21 - <span class="name">{c.camera}</span> 22 - </a> 21 + <GallerySectionRow 22 + kind="camera" 23 + camera={c.camera} 24 + href="/camera/{encodeURIComponent(c.camera)}" 25 + /> 23 26 {/each} 24 27 {/if} 25 28 </div> ··· 28 31 .index-page { 29 32 display: flex; 30 33 flex-direction: column; 31 - } 32 - .row { 33 - display: block; 34 - padding: 14px 16px; 35 - border-bottom: 1px solid var(--border); 36 - text-decoration: none; 37 - color: var(--text-primary); 38 - transition: background 0.12s; 39 - } 40 - .row:hover { 41 - background: var(--bg-hover); 42 - } 43 - .name { 44 - font-size: 15px; 45 - overflow: hidden; 46 - text-overflow: ellipsis; 47 - white-space: nowrap; 48 34 } 49 35 .state { 50 36 padding: 32px 16px;
+6 -22
app/routes/locations/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import GallerySectionRow from '$lib/components/molecules/GallerySectionRow.svelte' 3 4 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 4 5 import { createQuery } from '@tanstack/svelte-query' 5 6 import { locationsQuery } from '$lib/queries' ··· 17 18 <div class="state">No locations yet.</div> 18 19 {:else} 19 20 {#each locations.data as loc (loc.h3Index)} 20 - <a 21 - class="row" 21 + <GallerySectionRow 22 + kind="location" 23 + h3={loc.h3Index} 24 + name={loc.name} 22 25 href="/location/{encodeURIComponent(loc.h3Index)}?name={encodeURIComponent(loc.name)}" 23 - > 24 - <span class="name">{loc.name}</span> 25 - </a> 26 + /> 26 27 {/each} 27 28 {/if} 28 29 </div> ··· 31 32 .index-page { 32 33 display: flex; 33 34 flex-direction: column; 34 - } 35 - .row { 36 - display: block; 37 - padding: 14px 16px; 38 - border-bottom: 1px solid var(--border); 39 - text-decoration: none; 40 - color: var(--text-primary); 41 - transition: background 0.12s; 42 - } 43 - .row:hover { 44 - background: var(--bg-hover); 45 - } 46 - .name { 47 - font-size: 15px; 48 - overflow: hidden; 49 - text-overflow: ellipsis; 50 - white-space: nowrap; 51 35 } 52 36 .state { 53 37 padding: 32px 16px;