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

Compare changes

Choose any two refs to compare.

Changed files
+247 -60
__generated__
types
social
grain
gallery
lexicons
social
grain
gallery
static
+4
__generated__/lexicons.ts
··· 2341 2341 type: 'string', 2342 2342 format: 'at-uri', 2343 2343 }, 2344 + position: { 2345 + type: 'integer', 2346 + default: 0, 2347 + }, 2344 2348 }, 2345 2349 }, 2346 2350 },
+1
__generated__/types/social/grain/gallery/item.ts
··· 19 19 createdAt: string 20 20 gallery: string 21 21 item: string 22 + position: number 22 23 [k: string]: unknown 23 24 } 24 25
+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.14", 5 + "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.15", 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",
+4 -4
deno.lock
··· 2 2 "version": "4", 3 3 "specifiers": { 4 4 "jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0", 5 - "jsr:@bigmoves/bff@0.3.0-beta.14": "0.3.0-beta.14", 5 + "jsr:@bigmoves/bff@0.3.0-beta.15": "0.3.0-beta.15", 6 6 "jsr:@deno/gfm@0.10": "0.10.0", 7 7 "jsr:@denosaurs/emoji@0.3": "0.3.1", 8 8 "jsr:@denosaurs/plug@1": "1.0.5", ··· 92 92 "npm:jose" 93 93 ] 94 94 }, 95 - "@bigmoves/bff@0.3.0-beta.14": { 96 - "integrity": "2b94d1f58c9b035cb2a50e3161953ab5c8c158caf902eccd89ae0beb2db60edc", 95 + "@bigmoves/bff@0.3.0-beta.15": { 96 + "integrity": "934d0fab8cc73804099ccb5362fa89f5ef3cd6269a6613029131770c97cdfcb9", 97 97 "dependencies": [ 98 98 "jsr:@bigmoves/atproto-oauth-client", 99 99 "jsr:@std/assert@^1.0.13", ··· 1810 1810 }, 1811 1811 "workspace": { 1812 1812 "dependencies": [ 1813 - "jsr:@bigmoves/bff@0.3.0-beta.14", 1813 + "jsr:@bigmoves/bff@0.3.0-beta.15", 1814 1814 "jsr:@gfx/canvas@~0.5.8", 1815 1815 "jsr:@std/path@^1.0.9", 1816 1816 "npm:@atproto/syntax@0.4",
+4
lexicons/social/grain/gallery/item.json
··· 20 20 "item": { 21 21 "type": "string", 22 22 "format": "at-uri" 23 + }, 24 + "position": { 25 + "type": "integer", 26 + "default": 0 23 27 } 24 28 } 25 29 }
+150 -55
main.tsx
··· 58 58 const PUBLIC_URL = Deno.env.get("BFF_PUBLIC_URL") ?? "http://localhost:8080"; 59 59 const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 60 60 61 - let cssContentHash: string = ""; 62 - const staticJsFiles = new Map<string, string>(); 61 + const staticFilesHash = new Map<string, string>(); 63 62 64 63 bff({ 65 64 appName: "Grain Social", ··· 74 73 lexicons, 75 74 rootElement: Root, 76 75 onListen: async () => { 77 - const cssFileContent = await Deno.readFile( 78 - join(Deno.cwd(), "static", "styles.css"), 79 - ); 80 - const hashBuffer = await crypto.subtle.digest("SHA-256", cssFileContent); 81 - cssContentHash = Array.from(new Uint8Array(hashBuffer)) 82 - .map((b) => b.toString(16).padStart(2, "0")) 83 - .join(""); 84 76 for (const entry of Deno.readDirSync(join(Deno.cwd(), "static"))) { 85 - if (entry.isFile && entry.name.endsWith(".js")) { 77 + if ( 78 + entry.isFile && 79 + (entry.name.endsWith(".js") || entry.name.endsWith(".css")) 80 + ) { 86 81 const fileContent = await Deno.readFile( 87 82 join(Deno.cwd(), "static", entry.name), 88 83 ); ··· 90 85 const hash = Array.from(new Uint8Array(hashBuffer)) 91 86 .map((b) => b.toString(16).padStart(2, "0")) 92 87 .join(""); 93 - staticJsFiles.set(entry.name, hash); 88 + staticFilesHash.set(entry.name, hash); 94 89 } 95 90 } 96 91 }, ··· 152 147 if (!profile) return ctx.next(); 153 148 let follow: WithBffMeta<BskyFollow> | undefined; 154 149 if (ctx.currentUser) { 155 - follow = getFollow( 156 - profile.did, 157 - ctx.currentUser.did, 158 - ctx, 159 - ); 150 + follow = getFollow(profile.did, ctx.currentUser.did, ctx); 160 151 } 161 152 ctx.state.meta = [ 162 153 { ··· 200 191 ...getPageMeta(galleryLink(handle, rkey)), 201 192 ...getGalleryMeta(gallery), 202 193 ]; 203 - ctx.state.scripts = ["photo_dialog.js", "masonry.js"]; 194 + ctx.state.scripts = ["photo_dialog.js", "masonry.js", "sortable.js"]; 204 195 return ctx.render( 205 196 <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, 206 197 ); ··· 232 223 createdAt: new Date().toISOString(), 233 224 }, 234 225 ); 235 - return ctx.html( 236 - <FollowButton followeeDid={did} followUri={followUri} />, 237 - ); 226 + return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 238 227 }), 239 228 route("/follow/:did/:rkey", ["DELETE"], async (_req, params, ctx) => { 240 229 requireAuth(ctx); ··· 244 233 await ctx.deleteRecord( 245 234 `at://${ctx.currentUser.did}/app.bsky.graph.follow/${rkey}`, 246 235 ); 247 - return ctx.html( 248 - <FollowButton followeeDid={did} followUri={undefined} />, 249 - ); 236 + return ctx.html(<FollowButton followeeDid={did} followUri={undefined} />); 250 237 }), 251 238 route("/dialogs/gallery/new", (_req, _params, ctx) => { 252 239 requireAuth(ctx); ··· 258 245 const rkey = params.rkey; 259 246 const gallery = getGallery(handle, rkey, ctx); 260 247 return ctx.html(<GalleryCreateEditDialog gallery={gallery} />); 248 + }), 249 + route("/dialogs/gallery/:rkey/sort", (_req, params, ctx) => { 250 + requireAuth(ctx); 251 + const handle = ctx.currentUser.handle; 252 + const rkey = params.rkey; 253 + const gallery = getGallery(handle, rkey, ctx); 254 + if (!gallery) return ctx.next(); 255 + return ctx.html(<GallerySortDialog gallery={gallery} />); 261 256 }), 262 257 route("/onboard", (_req, _params, ctx) => { 263 258 requireAuth(ctx); ··· 583 578 584 579 return ctx.redirect(`/profile/${ctx.currentUser.handle}`); 585 580 }), 581 + route( 582 + "/actions/gallery/:rkey/sort", 583 + ["POST"], 584 + async (req, params, ctx) => { 585 + requireAuth(ctx); 586 + const galleryRkey = params.rkey; 587 + const galleryUri = 588 + `at://${ctx.currentUser.did}/social.grain.gallery/${galleryRkey}`; 589 + const { 590 + items, 591 + } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 592 + "social.grain.gallery.item", 593 + { 594 + where: [ 595 + { 596 + field: "gallery", 597 + equals: galleryUri, 598 + }, 599 + ], 600 + }, 601 + ); 602 + const itemsMap = new Map<string, WithBffMeta<GalleryItem>>(); 603 + for (const item of items) { 604 + itemsMap.set(item.item, item); 605 + } 606 + const formData = await req.formData(); 607 + const sortedItems = formData.getAll("item") as string[]; 608 + const updates = []; 609 + let position = 0; 610 + for (const sortedItemUri of sortedItems) { 611 + const item = itemsMap.get(sortedItemUri); 612 + if (!item) continue; 613 + updates.push({ 614 + collection: "social.grain.gallery.item", 615 + rkey: new AtUri(item.uri).rkey, 616 + data: { 617 + gallery: item.gallery, 618 + item: item.item, 619 + createdAt: item.createdAt, 620 + position, 621 + }, 622 + }); 623 + position++; 624 + } 625 + await ctx.updateRecords<WithBffMeta<GalleryItem>>(updates); 626 + return ctx.redirect( 627 + `/profile/${ctx.currentUser.handle}/${galleryRkey}`, 628 + ); 629 + }, 630 + ), 586 631 ...photoUploadRoutes(), 587 632 ...avatarUploadRoutes(), 588 633 ], ··· 667 712 }; 668 713 669 714 function getFollow(followeeDid: string, followerDid: string, ctx: BffContext) { 670 - const { items: [follow] } = ctx.indexService.getRecords< 671 - WithBffMeta<BskyFollow> 672 - >( 715 + const { 716 + items: [follow], 717 + } = ctx.indexService.getRecords<WithBffMeta<BskyFollow>>( 673 718 "app.bsky.graph.follow", 674 719 { 675 720 where: [ ··· 701 746 const { items: galleryItems } = ctx.indexService.getRecords< 702 747 WithBffMeta<GalleryItem> 703 748 >("social.grain.gallery.item", { 704 - orderBy: { field: "createdAt", direction: "asc" }, 749 + orderBy: { field: "position", direction: "asc" }, 705 750 where: [{ field: "gallery", in: galleryUris }], 706 751 }); 707 752 ··· 1055 1100 : null} 1056 1101 <script src="https://unpkg.com/htmx.org@1.9.10" /> 1057 1102 <script src="https://unpkg.com/hyperscript.org@0.9.14" /> 1103 + <script src="https://unpkg.com/sortablejs@1.15.6" /> 1058 1104 <style dangerouslySetInnerHTML={{ __html: CSS }} /> 1059 - <link rel="stylesheet" href={`/static/styles.css?${cssContentHash}`} /> 1105 + <link 1106 + rel="stylesheet" 1107 + href={`/static/styles.css?${staticFilesHash.get("styles.css")}`} 1108 + /> 1060 1109 <link rel="preconnect" href="https://fonts.googleapis.com" /> 1061 1110 <link 1062 1111 rel="preconnect" ··· 1075 1124 {scripts?.map((file) => ( 1076 1125 <script 1077 1126 key={file} 1078 - src={`/static/${file}?${staticJsFiles.get(file)}`} 1127 + src={`/static/${file}?${staticFilesHash.get(file)}`} 1079 1128 /> 1080 1129 ))} 1081 1130 </head> ··· 1085 1134 heading={ 1086 1135 <h1 class="font-['Jersey_20'] text-4xl text-zinc-900 dark:text-white"> 1087 1136 grain 1088 - <sub class="bottom-[0.75rem] text-[1rem]"> 1089 - beta 1090 - </sub> 1137 + <sub class="bottom-[0.75rem] text-[1rem]">beta</sub> 1091 1138 </h1> 1092 1139 } 1093 1140 profile={profile} ··· 1169 1216 <span class="text-zinc-950 dark:text-zinc-50 font-semibold text-"> 1170 1217 {profile.displayName || profile.handle} 1171 1218 </span>{" "} 1172 - <span class="truncate"> 1173 - @{profile.handle} 1174 - </span> 1219 + <span class="truncate">@{profile.handle}</span> 1175 1220 </a> 1176 1221 </div> 1177 1222 ); ··· 1287 1332 : { 1288 1333 children: ( 1289 1334 <> 1290 - <i class="fa-solid fa-plus mr-2" />Follow 1335 + <i class="fa-solid fa-plus mr-2" /> 1336 + Follow 1291 1337 </> 1292 1338 ), 1293 1339 "hx-post": `/follow/${followeeDid}`, ··· 1468 1514 ); 1469 1515 } 1470 1516 1471 - function UploadPage( 1472 - { handle, photos, returnTo }: Readonly< 1473 - { handle: string; photos: PhotoView[]; returnTo?: string } 1474 - >, 1475 - ) { 1517 + function UploadPage({ 1518 + handle, 1519 + photos, 1520 + returnTo, 1521 + }: Readonly<{ handle: string; photos: PhotoView[]; returnTo?: string }>) { 1476 1522 return ( 1477 1523 <div class="flex flex-col px-4 pt-4 mb-4 space-y-4"> 1478 1524 <div class="flex"> 1479 1525 <div class="flex-1"> 1480 1526 {returnTo 1481 1527 ? ( 1482 - <a 1483 - href={returnTo} 1484 - class="hover:underline" 1485 - > 1528 + <a href={returnTo} class="hover:underline"> 1486 1529 <i class="fa-solid fa-arrow-left mr-2" /> 1487 1530 Back to gallery 1488 1531 </a> ··· 1678 1721 > 1679 1722 Add photos 1680 1723 </Button> 1724 + <Button 1725 + variant="primary" 1726 + class="self-start w-full sm:w-fit" 1727 + hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/sort`} 1728 + hx-target="#layout" 1729 + hx-swap="afterbegin" 1730 + > 1731 + Sort order 1732 + </Button> 1681 1733 <ShareGalleryButton gallery={gallery} /> 1682 1734 </div> 1683 1735 ) ··· 1852 1904 ); 1853 1905 } 1854 1906 1855 - function ShareGalleryButton({ 1856 - gallery, 1857 - }: Readonly<{ gallery: GalleryView }>) { 1907 + function GallerySortDialog({ gallery }: Readonly<{ gallery: GalleryView }>) { 1908 + return ( 1909 + <Dialog> 1910 + <Dialog.Content class="dark:bg-zinc-950 relative"> 1911 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 1912 + <Dialog.Title>Sort gallery</Dialog.Title> 1913 + <p class="my-2 text-center">Drag photos to rearrange</p> 1914 + <form 1915 + hx-post={`/actions/gallery/${new AtUri(gallery.uri).rkey}/sort`} 1916 + hx-trigger="submit" 1917 + hx-swap="none" 1918 + > 1919 + <div class="sortable grid grid-cols-3 sm:grid-cols-5 gap-2 mt-2"> 1920 + {gallery?.items?.filter(isPhotoView).map((item) => ( 1921 + <div 1922 + key={item.cid} 1923 + class="relative aspect-square cursor-grab" 1924 + > 1925 + <input type="hidden" name="item" value={item.uri} /> 1926 + <img 1927 + src={item.fullsize} 1928 + alt={item.alt} 1929 + class="w-full h-full absolute object-cover" 1930 + /> 1931 + </div> 1932 + ))} 1933 + </div> 1934 + <div class="flex flex-col gap-2 mt-2"> 1935 + <Button 1936 + variant="primary" 1937 + type="submit" 1938 + class="w-full" 1939 + > 1940 + Save 1941 + </Button> 1942 + <Button 1943 + variant="secondary" 1944 + type="button" 1945 + class="w-full" 1946 + _={Dialog._closeOnClick} 1947 + > 1948 + Cancel 1949 + </Button> 1950 + </div> 1951 + </form> 1952 + </Dialog.Content> 1953 + </Dialog> 1954 + ); 1955 + } 1956 + 1957 + function ShareGalleryButton({ gallery }: Readonly<{ gallery: GalleryView }>) { 1858 1958 return ( 1859 1959 <> 1860 1960 <input ··· 2029 2129 ); 2030 2130 } 2031 2131 2032 - function AltTextButton({ 2033 - photoUri, 2034 - }: Readonly<{ photoUri: string }>) { 2132 + function AltTextButton({ photoUri }: Readonly<{ photoUri: string }>) { 2035 2133 return ( 2036 2134 <div 2037 2135 class="bg-zinc-950 dark:bg-zinc-950 py-[1px] px-[3px] absolute top-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" ··· 2559 2657 ]; 2560 2658 } 2561 2659 2562 - function publicGalleryLink( 2563 - handle: string, 2564 - galleryUri: string, 2565 - ): string { 2660 + function publicGalleryLink(handle: string, galleryUri: string): string { 2566 2661 return `${PUBLIC_URL}/profile/${handle}/${new AtUri(galleryUri).rkey}`; 2567 2662 }
+8
static/sortable.js
··· 1 + htmx.onLoad(function (content) { 2 + const sortables = content.querySelectorAll(".sortable"); 3 + for (const sortable of sortables) { 4 + new Sortable(sortable, { 5 + animation: 150, 6 + }); 7 + } 8 + });
+75
static/styles.css
··· 387 387 .shrink-0 { 388 388 flex-shrink: 0; 389 389 } 390 + .cursor-grab { 391 + cursor: grab; 392 + } 390 393 .cursor-pointer { 391 394 cursor: pointer; 392 395 } ··· 484 487 background-color: color-mix(in oklab, var(--color-black) 80%, transparent); 485 488 } 486 489 } 490 + .bg-sky-500 { 491 + background-color: var(--color-sky-500); 492 + } 487 493 .bg-zinc-100 { 488 494 background-color: var(--color-zinc-100); 489 495 } ··· 595 601 } 596 602 .ring-sky-500 { 597 603 --tw-ring-color: var(--color-sky-500); 604 + } 605 + .filter { 606 + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); 598 607 } 599 608 .group-data-\[added\=true\]\:block { 600 609 &:is(:where(.group)[data-added="true"] *) { ··· 754 763 syntax: "*"; 755 764 inherits: false; 756 765 } 766 + @property --tw-blur { 767 + syntax: "*"; 768 + inherits: false; 769 + } 770 + @property --tw-brightness { 771 + syntax: "*"; 772 + inherits: false; 773 + } 774 + @property --tw-contrast { 775 + syntax: "*"; 776 + inherits: false; 777 + } 778 + @property --tw-grayscale { 779 + syntax: "*"; 780 + inherits: false; 781 + } 782 + @property --tw-hue-rotate { 783 + syntax: "*"; 784 + inherits: false; 785 + } 786 + @property --tw-invert { 787 + syntax: "*"; 788 + inherits: false; 789 + } 790 + @property --tw-opacity { 791 + syntax: "*"; 792 + inherits: false; 793 + } 794 + @property --tw-saturate { 795 + syntax: "*"; 796 + inherits: false; 797 + } 798 + @property --tw-sepia { 799 + syntax: "*"; 800 + inherits: false; 801 + } 802 + @property --tw-drop-shadow { 803 + syntax: "*"; 804 + inherits: false; 805 + } 806 + @property --tw-drop-shadow-color { 807 + syntax: "*"; 808 + inherits: false; 809 + } 810 + @property --tw-drop-shadow-alpha { 811 + syntax: "<percentage>"; 812 + inherits: false; 813 + initial-value: 100%; 814 + } 815 + @property --tw-drop-shadow-size { 816 + syntax: "*"; 817 + inherits: false; 818 + } 757 819 @property --tw-shadow { 758 820 syntax: "*"; 759 821 inherits: false; ··· 826 888 --tw-space-x-reverse: 0; 827 889 --tw-border-style: solid; 828 890 --tw-font-weight: initial; 891 + --tw-blur: initial; 892 + --tw-brightness: initial; 893 + --tw-contrast: initial; 894 + --tw-grayscale: initial; 895 + --tw-hue-rotate: initial; 896 + --tw-invert: initial; 897 + --tw-opacity: initial; 898 + --tw-saturate: initial; 899 + --tw-sepia: initial; 900 + --tw-drop-shadow: initial; 901 + --tw-drop-shadow-color: initial; 902 + --tw-drop-shadow-alpha: 100%; 903 + --tw-drop-shadow-size: initial; 829 904 --tw-shadow: 0 0 #0000; 830 905 --tw-shadow-color: initial; 831 906 --tw-shadow-alpha: 100%;