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.

add goatcounter, update head meta

+122 -116
+1 -1
deno.json
··· 2 2 "imports": { 3 3 "$lexicon/": "./__generated__/", 4 4 "@atproto/syntax": "npm:@atproto/syntax@^0.4.0", 5 - "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.8", 5 + "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.9", 6 6 "@gfx/canvas": "jsr:@gfx/canvas@^0.5.8", 7 7 "@std/path": "jsr:@std/path@^1.0.9", 8 8 "@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+6 -7
deno.lock
··· 2 2 "version": "4", 3 3 "specifiers": { 4 4 "jsr:@bigmoves/atproto-oauth-client@0.1": "0.1.0", 5 - "jsr:@bigmoves/bff@0.3.0-beta.8": "0.3.0-beta.8", 5 + "jsr:@bigmoves/bff@0.3.0-beta.9": "0.3.0-beta.9", 6 6 "jsr:@denosaurs/plug@1": "1.0.5", 7 7 "jsr:@denosaurs/plug@1.0.5": "1.0.5", 8 8 "jsr:@gfx/canvas@~0.5.8": "0.5.8", 9 9 "jsr:@std/assert@0.214": "0.214.0", 10 10 "jsr:@std/assert@0.217": "0.217.0", 11 - "jsr:@std/assert@^1.0.12": "1.0.13", 11 + "jsr:@std/assert@^1.0.13": "1.0.13", 12 12 "jsr:@std/cache@0.2": "0.2.0", 13 13 "jsr:@std/cli@^1.0.17": "1.0.17", 14 14 "jsr:@std/encoding@0.214": "0.214.0", ··· 70 70 "npm:jose" 71 71 ] 72 72 }, 73 - "@bigmoves/bff@0.3.0-beta.8": { 74 - "integrity": "9f4193d02b9ff1a16f600fbb1ad5323c75285848457429172914a2b797787055", 73 + "@bigmoves/bff@0.3.0-beta.9": { 74 + "integrity": "8d2f37eeb3f006670255e2c4e99e4556f686bc8c2ea009287835666cc9c0452b", 75 75 "dependencies": [ 76 76 "jsr:@bigmoves/atproto-oauth-client", 77 - "jsr:@std/assert@^1.0.12", 77 + "jsr:@std/assert@^1.0.13", 78 78 "jsr:@std/cache", 79 79 "jsr:@std/http", 80 80 "jsr:@std/path@^1.0.8", ··· 88 88 "npm:multiformats@^13.3.2", 89 89 "npm:preact", 90 90 "npm:preact-render-to-string", 91 - "npm:sharp", 92 91 "npm:tailwind-merge" 93 92 ] 94 93 }, ··· 1609 1608 }, 1610 1609 "workspace": { 1611 1610 "dependencies": [ 1612 - "jsr:@bigmoves/bff@0.3.0-beta.8", 1611 + "jsr:@bigmoves/bff@0.3.0-beta.9", 1613 1612 "jsr:@gfx/canvas@~0.5.8", 1614 1613 "jsr:@std/path@^1.0.9", 1615 1614 "npm:@atproto/syntax@0.4",
+1
fly.toml
··· 15 15 BFF_LEXICON_DIR = './__generated__' 16 16 BFF_PORT = '8081' 17 17 BFF_PUBLIC_URL = 'https://grain.social' 18 + GOATCOUNTER_URL = 'https://grain.goatcounter.com/count' 18 19 19 20 [[mounts]] 20 21 source = "litefs"
+114 -106
main.tsx
··· 40 40 Layout, 41 41 Login, 42 42 Meta, 43 - type MetaProps, 43 + type MetaDescriptor, 44 44 Textarea, 45 45 } from "@bigmoves/bff/components"; 46 46 import { createCanvas, Image } from "@gfx/canvas"; ··· 48 48 import { formatDistanceStrict } from "date-fns"; 49 49 import { wrap } from "popmotion"; 50 50 import { ComponentChildren, JSX, VNode } from "preact"; 51 + 52 + const PUBLIC_URL = Deno.env.get("BFF_PUBLIC_URL") ?? "http://localhost:8080"; 53 + const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 51 54 52 55 let cssContentHash: string = ""; 53 56 ··· 67 70 const cssFileContent = await Deno.readFile( 68 71 join(Deno.cwd(), "static", "styles.css"), 69 72 ); 70 - const hashBuffer = await crypto.subtle.digest( 71 - "SHA-256", 72 - cssFileContent, 73 - ); 73 + const hashBuffer = await crypto.subtle.digest("SHA-256", cssFileContent); 74 74 cssContentHash = Array.from(new Uint8Array(hashBuffer)) 75 75 .map((b) => b.toString(16).padStart(2, "0")) 76 76 .join(""); ··· 118 118 }), 119 119 route("/", (_req, _params, ctx) => { 120 120 const items = getTimeline(ctx); 121 + ctx.state.meta = getPageMeta(""); 121 122 return ctx.render(<Timeline items={items} />); 122 123 }), 123 124 route("/profile/:handle", (req, params, ctx) => { ··· 130 131 if (!actor) return ctx.next(); 131 132 const profile = getActorProfile(actor.did, ctx); 132 133 if (!profile) return ctx.next(); 134 + ctx.state.meta = getPageMeta(profileLink(handle)); 133 135 if (tab) { 134 136 return ctx.html( 135 137 <ProfilePage ··· 159 161 favs = getGalleryFavs(gallery.uri, ctx); 160 162 } 161 163 if (!gallery) return ctx.next(); 162 - ctx.state.meta = getGalleryMeta(gallery); 164 + ctx.state.meta = [ 165 + ...getPageMeta(galleryLink(handle, rkey)), 166 + ...getGalleryMeta(gallery), 167 + ]; 163 168 ctx.state.scripts = ["photo_dialog.js", "masonry.js"]; 164 169 return ctx.render( 165 170 <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, ··· 168 173 route("/upload", (_req, _params, ctx) => { 169 174 requireAuth(ctx); 170 175 const photos = getActorPhotos(ctx.currentUser.did, ctx); 171 - return ctx.render( 172 - <UploadPage photos={photos} />, 173 - ); 176 + ctx.state.meta = getPageMeta("/upload"); 177 + return ctx.render(<UploadPage photos={photos} />); 174 178 }), 175 179 route("/dialogs/gallery/new", (_req, _params, ctx) => { 176 180 requireAuth(ctx); 177 - return ctx.html( 178 - <GalleryCreateEditDialog />, 179 - ); 181 + return ctx.html(<GalleryCreateEditDialog />); 180 182 }), 181 183 route("/dialogs/gallery/:rkey", (_req, params, ctx) => { 182 184 requireAuth(ctx); 183 185 const handle = ctx.currentUser.handle; 184 186 const rkey = params.rkey; 185 187 const gallery = getGallery(handle, rkey, ctx); 186 - return ctx.html( 187 - <GalleryCreateEditDialog gallery={gallery} />, 188 - ); 188 + return ctx.html(<GalleryCreateEditDialog gallery={gallery} />); 189 189 }), 190 190 route("/onboard", (_req, _params, ctx) => { 191 191 requireAuth(ctx); ··· 237 237 const image = gallery.items.filter(isPhotoView).find((item) => { 238 238 return item.cid === imageCid; 239 239 }); 240 - const imageAtIndex = gallery.items.filter(isPhotoView).findIndex( 241 - (image) => { 240 + const imageAtIndex = gallery.items 241 + .filter(isPhotoView) 242 + .findIndex((image) => { 242 243 return image.cid === imageCid; 243 - }, 244 - ); 244 + }); 245 245 const next = wrap(0, gallery.items.length, imageAtIndex + 1); 246 246 const prev = wrap(0, gallery.items.length, imageAtIndex - 1); 247 247 if (!image) return ctx.next(); ··· 354 354 const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 355 355 if (!gallery || !photo) return ctx.next(); 356 356 if ( 357 - gallery.items?.filter(isPhotoView).some((item) => 358 - item.uri === photoUri 359 - ) 357 + gallery.items 358 + ?.filter(isPhotoView) 359 + .some((item) => item.uri === photoUri) 360 360 ) { 361 361 return new Response(null, { status: 500 }); 362 362 } 363 - await ctx.createRecord<Gallery>( 364 - "social.grain.gallery.item", 365 - { 366 - gallery: galleryUri, 367 - item: photoUri, 368 - createdAt: new Date().toISOString(), 369 - }, 370 - ); 363 + await ctx.createRecord<Gallery>("social.grain.gallery.item", { 364 + gallery: galleryUri, 365 + item: photoUri, 366 + createdAt: new Date().toISOString(), 367 + }); 371 368 gallery.items = [ 372 369 ...(gallery.items ?? []), 373 370 photoToView(photo.did, photo), ··· 408 405 if (!galleryRkey || !photoRkey) return ctx.next(); 409 406 const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 410 407 if (!photo) return ctx.next(); 411 - const { items: [item] } = ctx.indexService.getRecords< 412 - WithBffMeta<GalleryItem> 413 - >( 408 + const { 409 + items: [item], 410 + } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 414 411 "social.grain.gallery.item", 415 412 { 416 413 where: [ ··· 426 423 }, 427 424 ); 428 425 if (!item) return ctx.next(); 429 - await ctx.deleteRecord( 430 - item.uri, 431 - ); 426 + await ctx.deleteRecord(item.uri); 432 427 const gallery = getGallery(ctx.currentUser.did, galleryRkey, ctx); 433 428 if (!gallery) return ctx.next(); 434 429 return ctx.html( ··· 479 474 ); 480 475 } 481 476 482 - await ctx.createRecord<WithBffMeta<Favorite>>( 483 - "social.grain.favorite", 484 - { 485 - subject: galleryUri, 486 - createdAt: new Date().toISOString(), 487 - }, 488 - ); 477 + await ctx.createRecord<WithBffMeta<Favorite>>("social.grain.favorite", { 478 + subject: galleryUri, 479 + createdAt: new Date().toISOString(), 480 + }); 489 481 490 482 const favs = getGalleryFavs(galleryUri, ctx); 491 483 ··· 535 527 type State = { 536 528 profile?: ProfileView; 537 529 scripts?: string[]; 538 - meta?: MetaProps[]; 530 + meta?: MetaDescriptor[]; 539 531 }; 540 532 541 533 function readFileAsDataURL(file: File): Promise<string> { ··· 614 606 ctx: BffContext, 615 607 galleries: WithBffMeta<Gallery>[], 616 608 ): Map<string, WithBffMeta<Photo>[]> { 617 - const galleryUris = galleries.map((gallery) => 618 - `at://${gallery.did}/social.grain.gallery/${new AtUri(gallery.uri).rkey}` 609 + const galleryUris = galleries.map( 610 + (gallery) => 611 + `at://${gallery.did}/social.grain.gallery/${new AtUri(gallery.uri).rkey}`, 619 612 ); 620 613 621 614 if (galleryUris.length === 0) return new Map(); ··· 852 845 } 853 846 const { items: galleries } = ctx.indexService.getRecords< 854 847 WithBffMeta<Gallery> 855 - >( 856 - "social.grain.gallery", 857 - { 858 - where: [{ field: "did", equals: did }], 859 - orderBy: { field: "createdAt", direction: "desc" }, 860 - }, 861 - ); 848 + >("social.grain.gallery", { 849 + where: [{ field: "did", equals: did }], 850 + orderBy: { field: "createdAt", direction: "desc" }, 851 + }); 862 852 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 863 853 const creator = getActorProfile(did, ctx); 864 854 if (!creator) return []; ··· 906 896 return results.items; 907 897 } 908 898 909 - function getGalleryMeta(gallery: GalleryView): MetaProps[] { 899 + function getPageMeta(pageUrl: string): MetaDescriptor[] { 910 900 return [ 911 - { property: "og:type", content: "website" }, 912 - { property: "og:site_name", content: "Atproto Image Gallery" }, 901 + { 902 + tagName: "link", 903 + property: "canonical", 904 + href: `${PUBLIC_URL}${pageUrl}`, 905 + }, 906 + { property: "og:site_name", content: "Grain Social" }, 907 + ]; 908 + } 909 + 910 + function getGalleryMeta(gallery: GalleryView): MetaDescriptor[] { 911 + return [ 912 + // { property: "og:type", content: "website" }, 913 913 { 914 914 property: "og:url", 915 - content: `${ 916 - Deno.env.get("BFF_PUBLIC_URL") ?? "http://localhost:8080" 917 - }/profile/${gallery.creator.handle}/${new AtUri(gallery.uri).rkey}`, 915 + content: `${PUBLIC_URL}/profile/${gallery.creator.handle}/${ 916 + new AtUri(gallery.uri).rkey 917 + }`, 918 918 }, 919 919 { property: "og:title", content: (gallery.record as Gallery).title }, 920 920 { ··· 937 937 <meta charset="UTF-8" /> 938 938 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 939 939 <Meta meta={props.ctx.state.meta} /> 940 + {GOATCOUNTER_URL 941 + ? ( 942 + <script 943 + data-goatcounter={GOATCOUNTER_URL} 944 + async 945 + src="//gc.zgo.at/count.js" 946 + /> 947 + ) 948 + : null} 940 949 <script src="https://unpkg.com/htmx.org@1.9.10" /> 941 950 <script src="https://unpkg.com/hyperscript.org@0.9.14" /> 942 951 <style dangerouslySetInnerHTML={{ __html: CSS }} /> ··· 961 970 <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 962 971 <Layout id="layout" class="dark:border-zinc-800"> 963 972 <Layout.Nav 964 - title={ 973 + heading={ 965 974 <h1 class="font-['Jersey_20'] text-4xl text-zinc-900 dark:text-white"> 966 975 grain 967 976 <sub class="bottom-[0.75rem] text-[1rem]">beta</sub> ··· 1217 1226 ? ( 1218 1227 <ul class="space-y-4 relative"> 1219 1228 {timelineItems.length 1220 - ? timelineItems.map((item) => ( 1221 - <TimelineItem item={item} key={item.itemUri} /> 1222 - )) 1229 + ? ( 1230 + timelineItems.map((item) => ( 1231 + <TimelineItem item={item} key={item.itemUri} /> 1232 + )) 1233 + ) 1223 1234 : <li>No activity yet.</li>} 1224 1235 </ul> 1225 1236 ) ··· 1477 1488 _="on load or htmx:afterSettle call computeMasonry()" 1478 1489 > 1479 1490 {gallery.items?.filter(isPhotoView)?.length 1480 - ? gallery?.items?.filter(isPhotoView)?.map((photo) => ( 1481 - <PhotoButton 1482 - key={photo.cid} 1483 - photo={photo} 1484 - gallery={gallery} 1485 - isCreator={isCreator} 1486 - isLoggedIn={isLoggedIn} 1487 - /> 1488 - )) 1491 + ? gallery?.items 1492 + ?.filter(isPhotoView) 1493 + ?.map((photo) => ( 1494 + <PhotoButton 1495 + key={photo.cid} 1496 + photo={photo} 1497 + gallery={gallery} 1498 + isCreator={isCreator} 1499 + isLoggedIn={isLoggedIn} 1500 + /> 1501 + )) 1489 1502 : null} 1490 1503 </div> 1491 1504 </div> 1492 1505 ); 1493 1506 } 1494 1507 1495 - function PhotoButton({ photo, gallery, isCreator, isLoggedIn }: Readonly<{ 1508 + function PhotoButton({ 1509 + photo, 1510 + gallery, 1511 + isCreator, 1512 + isLoggedIn, 1513 + }: Readonly<{ 1496 1514 photo: PhotoView; 1497 1515 gallery: GalleryView; 1498 1516 isCreator: boolean; ··· 1794 1812 <Button type="submit" variant="primary" class="w-full"> 1795 1813 Save 1796 1814 </Button> 1797 - <Dialog.Close class="w-full"> 1798 - Cancel 1799 - </Dialog.Close> 1815 + <Dialog.Close class="w-full">Cancel</Dialog.Close> 1800 1816 </div> 1801 1817 </form> 1802 1818 </Dialog.Content> ··· 1828 1844 ))} 1829 1845 </div> 1830 1846 <div class="w-full flex flex-col gap-2 mt-2"> 1831 - <Dialog.Close class="w-full"> 1832 - Close 1833 - </Dialog.Close> 1847 + <Dialog.Close class="w-full">Close</Dialog.Close> 1834 1848 </div> 1835 1849 </Dialog.Content> 1836 1850 </Dialog> ··· 1896 1910 cid: record.cid, 1897 1911 creator, 1898 1912 record, 1899 - items: items?.map((item) => itemToView(record.did, item)).filter( 1900 - isPhotoView, 1901 - ), 1913 + items: items 1914 + ?.map((item) => itemToView(record.did, item)) 1915 + .filter(isPhotoView), 1902 1916 indexedAt: record.indexedAt, 1903 1917 }; 1904 1918 } 1905 1919 1906 1920 function itemToView( 1907 1921 did: string, 1908 - item: WithBffMeta<Photo> | { 1909 - $type: string; 1910 - }, 1922 + item: 1923 + | WithBffMeta<Photo> 1924 + | { 1925 + $type: string; 1926 + }, 1911 1927 ): Un$Typed<PhotoView> | undefined { 1912 1928 if (isPhoto(item)) { 1913 1929 return photoToView(did, item); ··· 2092 2108 if (!uploadId) return ctx.next(); 2093 2109 const meta = ctx.blobMetaCache.get(uploadId); 2094 2110 if (!meta?.dataUrl || !meta?.blobRef) return ctx.next(); 2095 - const photoUri = await ctx.createRecord<Photo>( 2096 - "social.grain.photo", 2097 - { 2098 - photo: meta.blobRef, 2099 - aspectRatio: meta.dimensions?.width && meta.dimensions?.height 2100 - ? { 2101 - width: meta.dimensions.width, 2102 - height: meta.dimensions.height, 2103 - } 2104 - : undefined, 2105 - alt: "", 2106 - createdAt: new Date().toISOString(), 2107 - }, 2108 - ); 2109 - return ctx.html( 2110 - cb({ dataUrl: meta.dataUrl, uri: photoUri }), 2111 - ); 2111 + const photoUri = await ctx.createRecord<Photo>("social.grain.photo", { 2112 + photo: meta.blobRef, 2113 + aspectRatio: meta.dimensions?.width && meta.dimensions?.height 2114 + ? { 2115 + width: meta.dimensions.width, 2116 + height: meta.dimensions.height, 2117 + } 2118 + : undefined, 2119 + alt: "", 2120 + createdAt: new Date().toISOString(), 2121 + }); 2122 + return ctx.html(cb({ dataUrl: meta.dataUrl, uri: photoUri })); 2112 2123 }; 2113 2124 } 2114 2125 ··· 2136 2147 `/actions/photo/upload-done`, 2137 2148 ["GET"], 2138 2149 photoUploadDone(({ dataUrl, uri }) => ( 2139 - <PhotoPreview 2140 - src={dataUrl} 2141 - uri={uri} 2142 - /> 2150 + <PhotoPreview src={dataUrl} uri={uri} /> 2143 2151 )), 2144 2152 ), 2145 2153 ];
-2
static/styles.css
··· 8 8 --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 9 9 "Courier New", monospace; 10 10 --color-sky-500: oklch(68.5% 0.169 237.323); 11 - --color-slate-800: oklch(27.9% 0.041 260.031); 12 - --color-slate-900: oklch(20.8% 0.042 265.755); 13 11 --color-zinc-100: oklch(96.7% 0.001 286.375); 14 12 --color-zinc-200: oklch(92% 0.004 286.32); 15 13 --color-zinc-500: oklch(55.2% 0.016 285.938);