grain.social is a photo sharing platform built on atproto.

add goatcounter, update head meta

Changed files
+122 -116
static
+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);