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: minimal right sidebar with place and camera dedup

- Flatten sidebar cards into a single continuous minimal layout; search as
bordered input, uppercase section labels, compact footer with dot separators.
- Dedupe locations server-side: getLocations groups by (locality, region,
country) with USA→US country alias. getFeed?feed=location accepts a name
param and unions all H3 cells sharing the display label via case-insensitive
address matching.
- Return h3Cells on LocationItem so LocationMapBanner can render a centroid +
dynamic zoom across all cells in a place (was showing only one cell).
- Normalize camera names server-side in getCameras: strip manufacturer
legalese, dedup adjacent tokens, title-case all-caps brands. Rows that
collide after normalization merge. Camera feed matches by
normalization-equivalence so old raw URLs and new cleaned URLs both work.
- Add /cameras and /locations index pages with "See all →" links in the
sidebar when the section has more than seven items.

+580 -149
+32 -5
app/lib/components/atoms/LocationMapBanner.svelte
··· 1 1 <script lang="ts"> 2 2 import { cellToLatLng, isValidCell } from 'h3-js' 3 3 4 - let { h3Index }: { h3Index: string } = $props() 4 + let { h3Index, h3Cells }: { h3Index: string; h3Cells?: string[] } = $props() 5 + 6 + // If multiple cells are provided, compute the center and a zoom level that 7 + // fits the bounding box of their centroids. Falls back to the single-cell 8 + // rendering when only h3Index is given. 9 + const cells = $derived( 10 + (h3Cells?.length ? h3Cells : [h3Index]).filter((c) => c && isValidCell(c)), 11 + ) 12 + const valid = $derived(cells.length > 0) 13 + 14 + const points = $derived(cells.map((c) => cellToLatLng(c))) 15 + const lat = $derived(valid ? points.reduce((s, [la]) => s + la, 0) / points.length : 0) 16 + const lng = $derived(valid ? points.reduce((s, [, lo]) => s + lo, 0) / points.length : 0) 17 + 18 + // Pick a zoom that keeps the bbox within ~3 tiles wide. 19 + const zoom = $derived.by(() => { 20 + if (points.length < 2) return 11 21 + const lats = points.map((p) => p[0]) 22 + const lngs = points.map((p) => p[1]) 23 + const latSpan = Math.max(...lats) - Math.min(...lats) 24 + const lngSpan = Math.max(...lngs) - Math.min(...lngs) 25 + const maxSpan = Math.max(latSpan, lngSpan) 26 + // empirically: each zoom step halves the span shown in three tiles 27 + if (maxSpan > 8) return 5 28 + if (maxSpan > 4) return 6 29 + if (maxSpan > 2) return 7 30 + if (maxSpan > 1) return 8 31 + if (maxSpan > 0.5) return 9 32 + if (maxSpan > 0.2) return 10 33 + return 11 34 + }) 5 35 6 - const valid = $derived(isValidCell(h3Index)) 7 - const [lat, lng] = $derived(valid ? cellToLatLng(h3Index) : [0, 0]) 8 - const zoom = 11 9 - const maxTile = Math.pow(2, zoom) 36 + const maxTile = $derived(Math.pow(2, zoom)) 10 37 const tileX = $derived(Math.floor(((lng + 180) / 360) * maxTile)) 11 38 const tileY = $derived( 12 39 Math.floor(
+80 -74
app/lib/components/organisms/SidebarRight.svelte
··· 161 161 {/if} 162 162 </div> 163 163 164 - <div class="sidebar-card"> 165 - <div class="sidebar-card-header">Feeds</div> 164 + <section class="sidebar-section"> 165 + <h2 class="sidebar-section-header">Feeds</h2> 166 166 {#each $pinnedFeeds as feed, i (feed.id)} 167 167 {@const href = i === 0 ? '/' : feed.path} 168 168 {@const FeedIcon = feedIcon(feed)} ··· 183 183 <span class="sidebar-link-label">More feeds</span> 184 184 </a> 185 185 {/if} 186 - </div> 186 + </section> 187 187 188 188 {#if camerasQ.data?.length} 189 - <div class="sidebar-card"> 190 - <div class="sidebar-card-header">Cameras</div> 191 - <div class="camera-grid"> 192 - {#each (camerasQ.data ?? []).slice(0, 12) as c} 193 - <a class="camera-pill" href="/camera/{encodeURIComponent(c.camera)}">{c.camera}</a> 194 - {/each} 195 - </div> 196 - </div> 189 + <section class="sidebar-section"> 190 + <h2 class="sidebar-section-header">Cameras</h2> 191 + {#each (camerasQ.data ?? []).slice(0, 7) as c} 192 + <a class="sidebar-list-item" href="/camera/{encodeURIComponent(c.camera)}">{c.camera}</a> 193 + {/each} 194 + {#if (camerasQ.data ?? []).length > 7} 195 + <a class="sidebar-see-all" href="/cameras">See all →</a> 196 + {/if} 197 + </section> 197 198 {/if} 198 199 199 200 {#if locationsQ.data?.length} 200 - <div class="sidebar-card"> 201 - <div class="sidebar-card-header">Locations</div> 202 - <div class="camera-grid"> 203 - {#each (locationsQ.data ?? []).slice(0, 12) as loc} 204 - <a class="camera-pill" href="/location/{encodeURIComponent(loc.h3Index)}?name={encodeURIComponent(loc.name)}">{loc.name}</a> 205 - {/each} 206 - </div> 207 - </div> 201 + <section class="sidebar-section"> 202 + <h2 class="sidebar-section-header">Locations</h2> 203 + {#each (locationsQ.data ?? []).slice(0, 7) as loc} 204 + <a class="sidebar-list-item" href="/location/{encodeURIComponent(loc.h3Index)}?name={encodeURIComponent(loc.name)}">{loc.name}</a> 205 + {/each} 206 + {#if (locationsQ.data ?? []).length > 7} 207 + <a class="sidebar-see-all" href="/locations">See all →</a> 208 + {/if} 209 + </section> 208 210 {/if} 209 211 210 212 <div class="sidebar-footer"> 211 - <div class="footer-links"> 212 - <a href="/support/terms">Terms</a> 213 - <a href="/support/privacy">Privacy</a> 214 - <a href="/support/copyright">Copyright</a> 215 - <a href="/support/community-guidelines">Guidelines</a> 216 - </div> 217 - <span>Powered by <a href="https://atproto.com">AT Protocol</a></span> 213 + <a href="/support/terms">Terms</a> 214 + <span class="dot">·</span> 215 + <a href="/support/privacy">Privacy</a> 216 + <span class="dot">·</span> 217 + <a href="/support/copyright">Copyright</a> 218 + <span class="dot">·</span> 219 + <a href="/support/community-guidelines">Guidelines</a> 220 + <span class="dot">·</span> 221 + <a href="https://atproto.com">AT Protocol</a> 218 222 </div> 219 223 </aside> 220 224 ··· 245 249 } 246 250 .search-input { 247 251 width: 100%; 248 - background: var(--bg-elevated); 252 + background: transparent; 249 253 border: 1px solid var(--border); 250 - border-radius: 20px; 251 - padding: 9px 16px 9px 36px; 254 + border-radius: 8px; 255 + padding: 8px 36px; 252 256 color: var(--text-primary); 253 257 font-family: var(--font-body); 254 - font-size: 16px; 258 + font-size: 14px; 255 259 outline: none; 256 - transition: border-color 0.15s, background 0.15s; 260 + transition: border-color 0.15s; 257 261 } 258 262 .search-input::placeholder { color: var(--text-faint); } 259 - .search-input:focus { border-color: var(--grain); background: var(--bg-root); } 263 + .search-input:focus { border-color: var(--grain); } 264 + .search-icon { left: 12px; } 260 265 .search-clear { 261 266 position: absolute; 262 - right: 10px; 267 + right: 8px; 263 268 top: 50%; 264 269 transform: translateY(-50%); 265 270 background: none; 266 271 border: none; 267 272 color: var(--text-muted); 268 273 cursor: pointer; 269 - padding: 4px; 274 + padding: 2px; 270 275 display: flex; 271 276 align-items: center; 272 277 } ··· 333 338 white-space: nowrap; 334 339 } 335 340 336 - .sidebar-card { 337 - background: var(--bg-surface); 338 - border: 1px solid var(--border); 339 - border-radius: 14px; 341 + .sidebar-section { 342 + display: flex; 343 + flex-direction: column; 340 344 } 341 - .sidebar-card-header { 342 - font-family: var(--font-display); 343 - font-weight: 700; 344 - font-size: 17px; 345 - padding: 12px 16px; 345 + .sidebar-section-header { 346 + margin: 0 0 6px; 347 + padding: 0 4px; 348 + font-family: var(--font-body); 349 + font-weight: 600; 350 + font-size: 11px; 351 + letter-spacing: 0.08em; 352 + text-transform: uppercase; 353 + color: var(--text-faint); 346 354 } 347 355 .sidebar-link { 348 356 display: flex; 349 357 align-items: center; 350 358 gap: 10px; 351 - padding: 10px 16px; 359 + padding: 6px 4px; 352 360 font-size: 14px; 353 361 color: var(--text-secondary); 354 362 cursor: pointer; 355 - transition: background 0.12s; 363 + transition: color 0.12s; 356 364 text-decoration: none; 357 365 min-width: 0; 366 + border-radius: 4px; 358 367 } 359 368 .sidebar-link-label { 360 369 white-space: nowrap; ··· 362 371 text-overflow: ellipsis; 363 372 min-width: 0; 364 373 } 365 - .sidebar-link:hover { background: var(--bg-hover); color: var(--text-primary); } 366 - .sidebar-link:last-child { border-radius: 0 0 14px 14px; } 374 + .sidebar-link:hover { color: var(--text-primary); } 367 375 .sidebar-link.active { color: var(--grain); font-weight: 600; } 368 376 .sidebar-link.more-feeds { color: var(--grain); font-size: 13px; } 369 377 .sidebar-link-icon { ··· 374 382 justify-content: center; 375 383 } 376 384 377 - .camera-grid { 378 - display: flex; 379 - flex-wrap: wrap; 380 - gap: 6px; 381 - padding: 0 16px 14px; 382 - } 383 - .camera-pill { 384 - background: var(--bg-elevated); 385 - border: 1px solid var(--border); 385 + .sidebar-list-item { 386 + display: block; 387 + padding: 6px 4px; 388 + font-size: 14px; 386 389 color: var(--text-secondary); 387 - padding: 4px 12px; 388 - border-radius: 14px; 389 - font-size: 12px; 390 - font-weight: 500; 391 - cursor: pointer; 392 - transition: all 0.15s; 390 + text-decoration: none; 391 + transition: color 0.12s; 393 392 white-space: nowrap; 394 - font-family: var(--font-body); 395 - text-decoration: none; 393 + overflow: hidden; 394 + text-overflow: ellipsis; 396 395 } 397 - .camera-pill:hover { 398 - border-color: var(--grain); 399 - color: var(--text-primary); 396 + .sidebar-list-item:hover { color: var(--text-primary); } 397 + 398 + .sidebar-see-all { 399 + display: block; 400 + padding: 6px 4px; 401 + margin-top: 2px; 402 + font-size: 13px; 403 + color: var(--text-muted); 404 + text-decoration: none; 405 + transition: color 0.12s; 400 406 } 407 + .sidebar-see-all:hover { color: var(--grain); } 401 408 402 409 .sidebar-footer { 403 - padding: 12px 16px; 410 + padding: 4px 4px 12px; 404 411 font-size: 11px; 412 + line-height: 1.5; 405 413 color: var(--text-faint); 406 - line-height: 1.8; 407 - } 408 - .footer-links { 409 414 display: flex; 410 415 flex-wrap: wrap; 411 - gap: 4px 10px; 412 - margin-bottom: 4px; 416 + gap: 6px; 417 + align-items: center; 413 418 } 419 + .sidebar-footer .dot { color: var(--text-faint); } 414 420 .sidebar-footer a { color: var(--text-muted); text-decoration: none; } 415 - .sidebar-footer a:hover { text-decoration: underline; } 421 + .sidebar-footer a:hover { color: var(--text-primary); } 416 422 417 423 @media (max-width: 1060px) { .sidebar-right { display: none; } } 418 424 </style>
+8 -3
app/lib/queries.ts
··· 55 55 staleTime: 5 * 60_000, 56 56 }); 57 57 58 - export const locationFeedQuery = (location: string, limit = 50, f?: Fetch) => 58 + export const locationFeedQuery = (location: string, name?: string, limit = 50, f?: Fetch) => 59 59 queryOptions({ 60 - queryKey: ["getFeed", "location", location], 61 - queryFn: () => callXrpc("dev.hatk.getFeed", { feed: "location", location, limit }, f), 60 + queryKey: ["getFeed", "location", name || location], 61 + queryFn: () => 62 + callXrpc( 63 + "dev.hatk.getFeed", 64 + { feed: "location", location, ...(name ? { name } : {}), limit }, 65 + f, 66 + ), 62 67 staleTime: 60_000, 63 68 }); 64 69
+54
app/routes/cameras/+page.svelte
··· 1 + <script lang="ts"> 2 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import OGMeta from '$lib/components/atoms/OGMeta.svelte' 4 + import { createQuery } from '@tanstack/svelte-query' 5 + import { camerasQuery } from '$lib/queries' 6 + 7 + const cameras = createQuery(() => camerasQuery()) 8 + </script> 9 + 10 + <OGMeta title="Cameras - grain" /> 11 + <DetailHeader label="Cameras" /> 12 + 13 + <div class="index-page"> 14 + {#if cameras.isLoading} 15 + <div class="state">Loading…</div> 16 + {:else if !cameras.data?.length} 17 + <div class="state">No cameras yet.</div> 18 + {:else} 19 + {#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> 23 + {/each} 24 + {/if} 25 + </div> 26 + 27 + <style> 28 + .index-page { 29 + display: flex; 30 + 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 + } 49 + .state { 50 + padding: 32px 16px; 51 + text-align: center; 52 + color: var(--text-muted); 53 + } 54 + </style>
+10
app/routes/cameras/+page.ts
··· 1 + import { browser } from "$app/environment"; 2 + import { camerasQuery } from "$lib/queries"; 3 + import type { PageLoad } from "./$types"; 4 + 5 + export const load: PageLoad = async ({ parent, fetch }) => { 6 + const { queryClient } = await parent(); 7 + const prefetch = queryClient.prefetchQuery(camerasQuery(fetch)); 8 + if (!browser) await prefetch; 9 + return {}; 10 + };
+11 -4
app/routes/location/[h3]/+page.svelte
··· 4 4 import PinButton from '$lib/components/atoms/PinButton.svelte' 5 5 import LocationMapBanner from '$lib/components/atoms/LocationMapBanner.svelte' 6 6 import { createQuery } from '@tanstack/svelte-query' 7 - import { locationFeedQuery } from '$lib/queries' 7 + import { locationFeedQuery, locationsQuery } from '$lib/queries' 8 8 import { isAuthenticated } from '$lib/stores' 9 9 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 10 10 ··· 12 12 13 13 const h3Index = $derived(data.h3Index) 14 14 const name = $derived(data.name) 15 - const feed = createQuery(() => locationFeedQuery(h3Index)) 15 + const nameParam = $derived(data.nameParam) 16 + const feed = createQuery(() => locationFeedQuery(h3Index, nameParam ?? undefined)) 17 + const locations = createQuery(() => locationsQuery()) 18 + const h3Cells = $derived( 19 + nameParam 20 + ? locations.data?.find((l) => l.name === nameParam)?.h3Cells 21 + : undefined, 22 + ) 16 23 </script> 17 24 18 25 <OGMeta title="{name} - grain" /> ··· 23 30 {/if} 24 31 {/snippet} 25 32 </DetailHeader> 26 - <LocationMapBanner {h3Index} /> 33 + <LocationMapBanner {h3Index} {h3Cells} /> 27 34 {#if feed.isLoading} 28 35 <FeedList feed="location" params={{ location: h3Index }} skeleton /> 29 36 {:else} 30 37 <FeedList 31 38 feed="location" 32 - params={{ location: h3Index }} 39 + params={nameParam ? { location: h3Index, name: nameParam } : { location: h3Index }} 33 40 initialItems={feed.data?.items ?? []} 34 41 initialCursor={feed.data?.cursor} 35 42 />
+9 -4
app/routes/location/[h3]/+page.ts
··· 1 1 import { browser } from "$app/environment"; 2 - import { locationFeedQuery } from "$lib/queries"; 2 + import { locationFeedQuery, locationsQuery } from "$lib/queries"; 3 3 import type { PageLoad } from "./$types"; 4 4 5 5 export const load: PageLoad = async ({ url, params, parent, fetch }) => { 6 6 const h3Index = decodeURIComponent(params.h3); 7 - const name = url.searchParams.get("name") ?? h3Index; 7 + const nameParam = url.searchParams.get("name"); 8 + const name = nameParam ?? h3Index; 8 9 const { queryClient } = await parent(); 9 - const prefetch = queryClient.prefetchQuery(locationFeedQuery(h3Index, 50, fetch)); 10 + const prefetch = Promise.all([ 11 + queryClient.prefetchQuery(locationFeedQuery(h3Index, nameParam ?? undefined, 50, fetch)), 12 + // Prefetch locations so the map banner can render the full cell set. 13 + queryClient.prefetchQuery(locationsQuery(fetch)), 14 + ]); 10 15 if (!browser) await prefetch; 11 - return { h3Index, name }; 16 + return { h3Index, name, nameParam }; 12 17 };
+57
app/routes/locations/+page.svelte
··· 1 + <script lang="ts"> 2 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import OGMeta from '$lib/components/atoms/OGMeta.svelte' 4 + import { createQuery } from '@tanstack/svelte-query' 5 + import { locationsQuery } from '$lib/queries' 6 + 7 + const locations = createQuery(() => locationsQuery()) 8 + </script> 9 + 10 + <OGMeta title="Locations - grain" /> 11 + <DetailHeader label="Locations" /> 12 + 13 + <div class="index-page"> 14 + {#if locations.isLoading} 15 + <div class="state">Loading…</div> 16 + {:else if !locations.data?.length} 17 + <div class="state">No locations yet.</div> 18 + {:else} 19 + {#each locations.data as loc (loc.h3Index)} 20 + <a 21 + class="row" 22 + href="/location/{encodeURIComponent(loc.h3Index)}?name={encodeURIComponent(loc.name)}" 23 + > 24 + <span class="name">{loc.name}</span> 25 + </a> 26 + {/each} 27 + {/if} 28 + </div> 29 + 30 + <style> 31 + .index-page { 32 + display: flex; 33 + 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 + } 52 + .state { 53 + padding: 32px 16px; 54 + text-align: center; 55 + color: var(--text-muted); 56 + } 57 + </style>
+10
app/routes/locations/+page.ts
··· 1 + import { browser } from "$app/environment"; 2 + import { locationsQuery } from "$lib/queries"; 3 + import type { PageLoad } from "./$types"; 4 + 5 + export const load: PageLoad = async ({ parent, fetch }) => { 6 + const { queryClient } = await parent(); 7 + const prefetch = queryClient.prefetchQuery(locationsQuery(fetch)); 8 + if (!browser) await prefetch; 9 + return {}; 10 + };
+1 -1
hatk.generated.ts
··· 76 76 const getFollowingLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowing","defs":{"main":{"type":"query","description":"Get users that a given user follows.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"totalCount":{"type":"integer"},"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowing#followingItem"}},"cursor":{"type":"string"}}}}},"followingItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"viewer":{"type":"ref","ref":"social.grain.unspecced.getFollowing#viewerState"}}},"viewerState":{"type":"object","properties":{"following":{"type":"string","format":"at-uri"}}}}} as const 77 77 const getGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.getGallery","defs":{"main":{"type":"query","description":"Get a single gallery view by AT URI.","parameters":{"type":"params","required":["gallery"],"properties":{"gallery":{"type":"string","format":"at-uri","description":"The gallery AT URI."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["gallery"],"properties":{"gallery":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}}}}}}} as const 78 78 const getKnownFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getKnownFollowers","defs":{"main":{"type":"query","description":"Get followers of a given actor that the viewer also follows.","parameters":{"type":"params","required":["actor","viewer"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":50,"default":50}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getKnownFollowers#followerItem"}}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"}}}}} as const 79 - const getLocationsLex = {"lexicon":1,"id":"social.grain.unspecced.getLocations","defs":{"main":{"type":"query","description":"Get top locations by gallery count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"locations":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getLocations#locationItem"}}}}}},"locationItem":{"type":"object","required":["name","h3Index","galleryCount"],"properties":{"name":{"type":"string"},"h3Index":{"type":"string"},"galleryCount":{"type":"integer"}}}}} as const 79 + const getLocationsLex = {"lexicon":1,"id":"social.grain.unspecced.getLocations","defs":{"main":{"type":"query","description":"Get top locations by gallery count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"locations":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getLocations#locationItem"}}}}}},"locationItem":{"type":"object","required":["name","h3Index","galleryCount"],"properties":{"name":{"type":"string"},"h3Index":{"type":"string","description":"Canonical H3 cell for the place (densest cell in the group)."},"galleryCount":{"type":"integer"},"h3Cells":{"type":"array","description":"All H3 cells whose galleries map to this place. Ordered by gallery count desc.","items":{"type":"string"}}}}}} as const 80 80 const getMutesLex = {"lexicon":1,"id":"social.grain.unspecced.getMutes","defs":{"main":{"type":"query","description":"Get the viewer's muted users.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getMutes#muteItem"}},"cursor":{"type":"string"}}}}},"muteItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"avatar":{"type":"string"}}}}} as const 81 81 const getNotificationsLex = {"lexicon":1,"id":"social.grain.unspecced.getNotifications","defs":{"main":{"type":"query","description":"Get notifications for the authenticated user.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"},"countOnly":{"type":"boolean","description":"If true, only return unseenCount without hydrating notifications."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["notifications"],"properties":{"notifications":{"type":"array","items":{"type":"ref","ref":"#notificationItem"}},"cursor":{"type":"string"},"unseenCount":{"type":"integer"}}}}},"notificationItem":{"type":"object","required":["uri","reason","createdAt","author"],"properties":{"uri":{"type":"string","format":"at-uri"},"reason":{"type":"string","knownValues":["gallery-favorite","gallery-comment","gallery-comment-mention","gallery-mention","comment-favorite","story-favorite","story-comment","reply","follow"]},"createdAt":{"type":"string","format":"datetime"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"galleryUri":{"type":"string","format":"at-uri"},"galleryTitle":{"type":"string"},"galleryThumb":{"type":"string"},"storyUri":{"type":"string","format":"at-uri"},"storyThumb":{"type":"string"},"commentText":{"type":"string"},"replyToText":{"type":"string"}}}}} as const 82 82 const getStoriesLex = {"lexicon":1,"id":"social.grain.unspecced.getStories","defs":{"main":{"type":"query","description":"Get a user's active stories (posted within the last 24 hours).","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["stories"],"properties":{"stories":{"type":"array","items":{"type":"ref","ref":"social.grain.story.defs#storyView"}}}}}}}} as const
+10 -2
lexicons/social/grain/unspecced/getLocations.json
··· 26 26 "required": ["name", "h3Index", "galleryCount"], 27 27 "properties": { 28 28 "name": { "type": "string" }, 29 - "h3Index": { "type": "string" }, 30 - "galleryCount": { "type": "integer" } 29 + "h3Index": { 30 + "type": "string", 31 + "description": "Canonical H3 cell for the place (densest cell in the group)." 32 + }, 33 + "galleryCount": { "type": "integer" }, 34 + "h3Cells": { 35 + "type": "array", 36 + "description": "All H3 cells whose galleries map to this place. Ordered by gallery count desc.", 37 + "items": { "type": "string" } 38 + } 31 39 } 32 40 } 33 41 }
+25 -3
server/feeds/camera.ts
··· 1 1 // Parameterized camera feed. Usage: 2 2 // GET /xrpc/dev.hatk.getFeed?feed=camera&camera=Sony+A7III&limit=50 3 + // 4 + // Matches galleries by *normalized* camera name so the param can be either a 5 + // raw EXIF string ("RICOH IMAGING COMPANY, LTD. RICOH GR III") or the cleaned 6 + // display string ("Ricoh GR III") — both resolve to the same set of galleries. 3 7 4 8 import { defineFeed } from "$hatk"; 5 9 import { hydrateGalleries } from "../hydrate/galleries.ts"; 6 10 import { hideLabelsFilter } from "../labels/_hidden.ts"; 7 11 import { blockMuteFilter } from "../filters/blockMute.ts"; 12 + import { cleanCameraName } from "../helpers/cameraName.ts"; 8 13 9 14 export default defineFeed({ 10 15 collection: "social.grain.gallery", ··· 16 21 const camera = ctx.params.camera; 17 22 if (!camera) return ctx.ok({ uris: [] }); 18 23 24 + const target = cleanCameraName(camera); 25 + 26 + // Find every raw make+model string that normalizes to the requested camera. 27 + const distinctRows = (await ctx.db.query(` 28 + SELECT DISTINCT make || ' ' || model AS raw 29 + FROM "social.grain.photo.exif" 30 + WHERE make IS NOT NULL AND model IS NOT NULL 31 + `)) as { raw: string }[]; 32 + 33 + const matchingRaws = distinctRows 34 + .filter((r) => cleanCameraName(r.raw) === target) 35 + .map((r) => r.raw); 36 + 37 + if (!matchingRaws.length) return ctx.ok({ uris: [] }); 38 + 19 39 const viewer = ctx.viewer?.did; 20 - const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : ""; 40 + const placeholders = matchingRaws.map((_, i) => `$${i + 1}`).join(","); 41 + let p = matchingRaws.length + 1; 42 + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", `$${p++}`)}` : ""; 21 43 const bmParams = viewer ? [viewer] : []; 22 44 23 45 const { rows, cursor } = await ctx.paginate<{ uri: string }>( ··· 27 49 AND EXISTS ( 28 50 SELECT 1 FROM "social.grain.gallery.item" gi 29 51 JOIN "social.grain.photo.exif" e ON e.photo = gi.item 30 - WHERE gi.gallery = t.uri AND (e.make || ' ' || e.model) = $1 52 + WHERE gi.gallery = t.uri AND (e.make || ' ' || e.model) IN (${placeholders}) 31 53 ) 32 54 AND ${hideLabelsFilter("t.uri")} 33 55 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 34 56 ${bmFilter}`, 35 - { orderBy: "t.created_at", params: [camera, ...bmParams] }, 57 + { orderBy: "t.created_at", params: [...matchingRaws, ...bmParams] }, 36 58 ); 37 59 38 60 return ctx.ok({ uris: rows.map((r) => r.uri), cursor });
+75
server/feeds/location.ts
··· 9 9 import { getResolution, cellToParent } from "h3-js"; 10 10 import { hideLabelsFilter } from "../labels/_hidden.ts"; 11 11 import { blockMuteFilter } from "../filters/blockMute.ts"; 12 + import { expandCountryAliases } from "../helpers/country.ts"; 12 13 13 14 export default defineFeed({ 14 15 collection: "social.grain.gallery", ··· 17 18 hydrate: hydrateGalleries, 18 19 19 20 async generate(ctx) { 21 + const name = typeof ctx.params.name === "string" ? ctx.params.name.trim() : ""; 20 22 const location = ctx.params.location; 23 + 24 + // Name-based query: unions all H3 cells sharing the same display label. 25 + // Preferred over H3 when provided, since multiple res-5 cells can carry the 26 + // same label (e.g. two res-5 cells both labeled "New York, New York, US"). 27 + if (name) { 28 + // Parse the display name into plausible address interpretations. The 29 + // sidebar's display format is `[locality, region, country].filter(Boolean).join(", ")`, 30 + // so the last part is always country (when any address is present) and 31 + // the position of earlier parts depends on which fields were populated. 32 + // A 2-part name like "Paris, FR" is locality+country, while "Oregon, US" 33 + // is region+country — so we try both and union the matches. 34 + const parts = name 35 + .split(",") 36 + .map((s) => s.trim()) 37 + .filter(Boolean); 38 + 39 + type Interp = { locality?: string; region?: string; country?: string }; 40 + const interps: Interp[] = []; 41 + if (parts.length >= 3) { 42 + interps.push({ locality: parts[0], region: parts[1], country: parts[2] }); 43 + } else if (parts.length === 2) { 44 + interps.push({ locality: parts[0], country: parts[1] }); 45 + interps.push({ region: parts[0], country: parts[1] }); 46 + } else if (parts.length === 1) { 47 + interps.push({ country: parts[0] }); 48 + } 49 + 50 + const viewer = ctx.viewer?.did; 51 + const params: any[] = []; 52 + let p = 1; 53 + 54 + const interpClauses: string[] = []; 55 + for (const interp of interps) { 56 + const matches: string[] = []; 57 + if (interp.locality) { 58 + matches.push(`UPPER(json_extract(t.address, '$.locality')) = UPPER($${p++})`); 59 + params.push(interp.locality); 60 + } 61 + if (interp.region) { 62 + matches.push(`UPPER(json_extract(t.address, '$.region')) = UPPER($${p++})`); 63 + params.push(interp.region); 64 + } 65 + if (interp.country) { 66 + // Expand "US"/"USA"/etc. all together. 67 + const aliases = expandCountryAliases(interp.country); 68 + const placeholders = aliases.map(() => `$${p++}`).join(","); 69 + matches.push(`UPPER(json_extract(t.address, '$.country')) IN (${placeholders})`); 70 + params.push(...aliases); 71 + } 72 + if (matches.length) interpClauses.push(`(${matches.join(" AND ")})`); 73 + } 74 + 75 + // Also match records whose raw location.name equals the requested name — 76 + // covers galleries with only a custom location label (no structured address). 77 + interpClauses.push(`json_extract(t.location, '$.name') = $${p++}`); 78 + params.push(name); 79 + 80 + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", `$${p++}`)}` : ""; 81 + if (viewer) params.push(viewer); 82 + 83 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 84 + `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t 85 + LEFT JOIN _repos r ON t.did = r.did 86 + WHERE (r.status IS NULL OR r.status != 'takendown') 87 + AND (${interpClauses.join(" OR ")}) 88 + AND ${hideLabelsFilter("t.uri")} 89 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 90 + ${bmFilter}`, 91 + { orderBy: "t.created_at", params }, 92 + ); 93 + return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); 94 + } 95 + 21 96 if (!location) return ctx.ok({ uris: [] }); 22 97 23 98 const res = getResolution(location);
+47
server/helpers/cameraName.ts
··· 1 + // Normalize raw EXIF make+model strings for display. 2 + // 3 + // Examples: 4 + // "RICOH IMAGING COMPANY, LTD. GR II" → "Ricoh GR II" 5 + // "RICOH IMAGING COMPANY, LTD. RICOH GR III" → "Ricoh GR III" 6 + // "NIKON CORPORATION NIKON D600" → "Nikon D600" 7 + // "OLYMPUS IMAGING CORP. TG-3" → "Olympus TG-3" 8 + // "FUJI PHOTO FILM CO., LTD. SP-3000" → "Fuji Photo Film SP-3000" 9 + // "LEICA CAMERA AG LEICA Q (Typ 116)" → "Leica Q (Typ 116)" 10 + // "KONICA MINOLTA ALPHA SWEET DIGITAL" → "Konica Minolta Alpha Sweet Digital" 11 + // "samsung Galaxy S24+" → "Samsung Galaxy S24+" 12 + // "Apple iPhone 14 Pro" → "Apple iPhone 14 Pro" (no change) 13 + // "SONY ILCE-7M3" → "Sony ILCE-7M3" (model code preserved) 14 + // "FUJIFILM X-T30 II" → "Fujifilm X-T30 II" (roman numeral preserved) 15 + 16 + export function cleanCameraName(raw: string): string { 17 + let s = raw 18 + .replace(/\bIMAGING COMPANY,?\s*LTD\.?/gi, "") 19 + .replace(/\bIMAGING CORP\.?/gi, "") 20 + .replace(/\bPHOTO FILM CO\.,?\s*LTD\.?/gi, "PHOTO FILM") 21 + .replace(/\bCAMERA AG\b/gi, "") 22 + .replace(/\bCORPORATION\b/gi, "") 23 + .replace(/\bCO\.,?\s*LTD\.?/gi, "") 24 + .replace(/\bGmbH\b/g, "") 25 + .replace(/\s+/g, " ") 26 + .trim(); 27 + 28 + // Dedup adjacent identical tokens (case-insensitive) — e.g. "NIKON NIKON D300". 29 + const tokens = s.split(" "); 30 + const deduped: string[] = []; 31 + for (const t of tokens) { 32 + const prev = deduped[deduped.length - 1]; 33 + if (!prev || prev.toUpperCase() !== t.toUpperCase()) deduped.push(t); 34 + } 35 + 36 + // Title-case brand-like tokens: 37 + // - ALL-CAPS letters ≥ 4 chars (brands: NIKON, FUJIFILM, KONICA, MINOLTA) 38 + // - all-lowercase letters ≥ 2 chars (samsung, motorola) 39 + // Skip tokens with digits, hyphens, roman numerals, or mixed case. 40 + const titled = deduped.map((w) => { 41 + if (/^[A-Z]{4,}$/.test(w)) return w[0] + w.slice(1).toLowerCase(); 42 + if (/^[a-z]{2,}$/.test(w)) return w[0].toUpperCase() + w.slice(1); 43 + return w; 44 + }); 45 + 46 + return titled.join(" ").trim(); 47 + }
+29
server/helpers/country.ts
··· 1 + // Country code normalization for place-grouping in the AppView index. 2 + // 3 + // Third-party clients writing to the grain lexicon can put any string in 4 + // address.country, so we normalize at query/aggregation time. Variants are 5 + // upper-cased on match, so spelling case doesn't matter. 6 + 7 + // Only the variants actually observed in the data. Add more as they appear. 8 + export const COUNTRY_ALIASES: Record<string, string> = { 9 + USA: "US", 10 + }; 11 + 12 + /** Return the canonical ISO-2 code for a country string, or null if unrecognized/empty. */ 13 + export function normalizeCountry(raw: string | null | undefined): string | null { 14 + if (!raw) return null; 15 + const s = raw.trim().toUpperCase(); 16 + if (!s) return null; 17 + return COUNTRY_ALIASES[s] ?? s; 18 + } 19 + 20 + /** Return all raw country strings that normalize to the same canonical code, upper-cased. */ 21 + export function expandCountryAliases(raw: string): string[] { 22 + const canon = normalizeCountry(raw); 23 + if (!canon) return []; 24 + const set = new Set<string>([canon]); 25 + for (const [alias, c] of Object.entries(COUNTRY_ALIASES)) { 26 + if (c === canon) set.add(alias); 27 + } 28 + return [...set]; 29 + }
+22 -4
server/xrpc/getCameras.ts
··· 1 1 // Returns top cameras by photo count (stale-while-revalidate, 5min TTL). 2 2 // GET /xrpc/social.grain.unspecced.getCameras 3 + // 4 + // Normalizes raw EXIF make/model strings before returning them so every 5 + // client gets consistent, human-readable names. Rows that collide after 6 + // normalization are merged (e.g. "RICOH IMAGING COMPANY, LTD. GR III" and 7 + // a hypothetical "Ricoh GR III" fold into one entry with summed counts). 3 8 4 9 import { defineQuery } from "$hatk"; 10 + import { cleanCameraName } from "../helpers/cameraName.ts"; 5 11 6 12 type Camera = { camera: string; photoCount: number }; 7 13 let cache: { data: Camera[]; expires: number } | null = null; 8 14 const TTL = 5 * 60 * 1000; 9 15 10 16 async function refresh(db: any) { 11 - const rows = await db.query(` 17 + const rows = (await db.query(` 12 18 SELECT make || ' ' || model AS camera, CAST(COUNT(*) AS INTEGER) AS photo_count 13 19 FROM "social.grain.photo.exif" 14 20 WHERE make IS NOT NULL AND model IS NOT NULL 15 21 GROUP BY make, model 16 22 ORDER BY photo_count DESC, camera ASC 17 - LIMIT 30 18 - `); 19 - const data = rows.map((r: any) => ({ camera: r.camera, photoCount: r.photo_count })); 23 + `)) as { camera: string; photo_count: number }[]; 24 + 25 + // Merge rows that collide after normalization. 26 + const merged = new Map<string, number>(); 27 + for (const r of rows) { 28 + const clean = cleanCameraName(r.camera); 29 + if (!clean) continue; 30 + merged.set(clean, (merged.get(clean) ?? 0) + r.photo_count); 31 + } 32 + 33 + const data: Camera[] = [...merged.entries()] 34 + .map(([camera, photoCount]) => ({ camera, photoCount })) 35 + .sort((a, b) => b.photoCount - a.photoCount || a.camera.localeCompare(b.camera)) 36 + .slice(0, 30); 37 + 20 38 cache = { data, expires: Date.now() + TTL }; 21 39 return data; 22 40 }
+100 -49
server/xrpc/getLocations.ts
··· 1 - // Returns top locations by gallery count, grouped at region level (stale-while-revalidate, 5min TTL). 1 + // Returns top locations by gallery count (stale-while-revalidate, 5min TTL). 2 2 // GET /xrpc/social.grain.unspecced.getLocations 3 + // 4 + // Places are identified by their structured address fields when available 5 + // (normalized country + region + locality), falling back to location.name and 6 + // finally to an H3 res-5 cell for records missing address data. This 7 + // eliminates the old H3-cell-first grouping, which could produce duplicate 8 + // entries when a city's photos spanned multiple res-5 parent cells. 3 9 4 10 import { defineQuery } from "$hatk"; 5 11 import { getResolution, cellToParent } from "h3-js"; 12 + import { normalizeCountry } from "../helpers/country.ts"; 6 13 7 - type LocationItem = { name: string; h3Index: string; galleryCount: number }; 14 + type LocationItem = { 15 + name: string; 16 + h3Index: string; 17 + galleryCount: number; 18 + h3Cells: string[]; 19 + }; 8 20 let cache: { data: LocationItem[]; expires: number } | null = null; 9 21 const TTL = 5 * 60 * 1000; 10 22 23 + type Row = { 24 + name: string | null; 25 + h3_index: string | null; 26 + locality: string | null; 27 + region: string | null; 28 + country: string | null; 29 + }; 30 + 31 + function computeKey(r: Row): string | null { 32 + const locality = r.locality?.trim() || null; 33 + const region = r.region?.trim() || null; 34 + const country = normalizeCountry(r.country); 35 + 36 + if (locality || region || country) { 37 + // Address-based key — lowercased for case-insensitive grouping; 38 + // country already canonicalized via normalizeCountry. 39 + return `A:${country ?? ""}|${region?.toLowerCase() ?? ""}|${locality?.toLowerCase() ?? ""}`; 40 + } 41 + if (r.name?.trim()) { 42 + return `N:${r.name.trim().toLowerCase()}`; 43 + } 44 + if (r.h3_index) { 45 + try { 46 + const res = getResolution(r.h3_index); 47 + const parent = res <= 5 ? r.h3_index : cellToParent(r.h3_index, 5); 48 + return `H:${parent}`; 49 + } catch { 50 + return null; 51 + } 52 + } 53 + return null; 54 + } 55 + 56 + function computeDisplayName(r: Row): string | null { 57 + const parts = [r.locality, r.region, r.country].map((s) => s?.trim() || null).filter(Boolean); 58 + if (parts.length) return parts.join(", "); 59 + return r.name?.trim() || null; 60 + } 61 + 11 62 async function refresh(db: any) { 12 - // Fetch all galleries with locations, then aggregate by region in application code 13 63 const rows = (await db.query(` 14 64 SELECT json_extract(location, '$.name') AS name, 15 65 json_extract(location, '$.value') AS h3_index, ··· 18 68 json_extract(address, '$.country') AS country 19 69 FROM "social.grain.gallery" 20 70 WHERE location IS NOT NULL 21 - `)) as { 22 - name: string; 23 - h3_index: string; 24 - locality: string | null; 25 - region: string | null; 26 - country: string | null; 27 - }[]; 71 + `)) as Row[]; 28 72 29 - // Group by region-level H3 cell, picking the most common locality name 30 - const regionMap = new Map< 31 - string, 32 - { nameCounts: Map<string, number>; h3Index: string; count: number } 33 - >(); 73 + type Group = { 74 + nameCounts: Map<string, number>; 75 + h3Counts: Map<string, number>; 76 + count: number; 77 + }; 78 + const groups = new Map<string, Group>(); 79 + 34 80 for (const row of rows) { 35 - if (!row.h3_index) continue; 36 - let regionH3: string; 37 - try { 38 - const res = getResolution(row.h3_index); 39 - regionH3 = res <= 5 ? row.h3_index : cellToParent(row.h3_index, 5); 40 - } catch { 41 - continue; 81 + const key = computeKey(row); 82 + if (!key) continue; 83 + const displayName = computeDisplayName(row); 84 + if (!displayName) continue; 85 + 86 + let g = groups.get(key); 87 + if (!g) { 88 + g = { nameCounts: new Map(), h3Counts: new Map(), count: 0 }; 89 + groups.set(key, g); 42 90 } 43 - const displayName = 44 - [row.locality, row.region, row.country].filter(Boolean).join(", ") || row.name; 45 - const existing = regionMap.get(regionH3); 46 - if (existing) { 47 - existing.count++; 48 - existing.nameCounts.set(displayName, (existing.nameCounts.get(displayName) ?? 0) + 1); 49 - } else { 50 - regionMap.set(regionH3, { 51 - nameCounts: new Map([[displayName, 1]]), 52 - h3Index: regionH3, 53 - count: 1, 54 - }); 91 + g.count++; 92 + g.nameCounts.set(displayName, (g.nameCounts.get(displayName) ?? 0) + 1); 93 + if (row.h3_index) { 94 + g.h3Counts.set(row.h3_index, (g.h3Counts.get(row.h3_index) ?? 0) + 1); 55 95 } 56 96 } 57 97 58 - const data = [...regionMap.values()] 59 - .map((r) => { 60 - let bestName = ""; 61 - let bestCount = 0; 62 - for (const [name, count] of r.nameCounts) { 63 - if (count > bestCount) { 64 - bestCount = count; 65 - bestName = name; 66 - } 98 + const data: LocationItem[] = []; 99 + for (const g of groups.values()) { 100 + let bestName = ""; 101 + let bestNameCount = 0; 102 + for (const [n, c] of g.nameCounts) { 103 + if (c > bestNameCount) { 104 + bestNameCount = c; 105 + bestName = n; 67 106 } 68 - return { name: bestName, h3Index: r.h3Index, galleryCount: r.count }; 69 - }) 70 - .sort((a, b) => b.galleryCount - a.galleryCount || a.name.localeCompare(b.name)) 71 - .slice(0, 30); 107 + } 108 + // Sort cells by count desc — canonical is the densest, full list used for map bbox. 109 + const sortedCells = [...g.h3Counts.entries()].sort((a, b) => b[1] - a[1]).map(([h]) => h); 110 + const bestH3 = sortedCells[0] ?? ""; 111 + if (bestName && bestH3) { 112 + data.push({ 113 + name: bestName, 114 + h3Index: bestH3, 115 + galleryCount: g.count, 116 + h3Cells: sortedCells, 117 + }); 118 + } 119 + } 120 + 121 + data.sort((a, b) => b.galleryCount - a.galleryCount || a.name.localeCompare(b.name)); 122 + const top = data.slice(0, 30); 72 123 73 - cache = { data, expires: Date.now() + TTL }; 74 - return data; 124 + cache = { data: top, expires: Date.now() + TTL }; 125 + return top; 75 126 } 76 127 77 128 export default defineQuery("social.grain.unspecced.getLocations", async (ctx) => {