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