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

add gallery sorting

Changed files
+143 -89
__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",
+1 -1
lexicons/social/grain/gallery/item.json
··· 21 21 "type": "string", 22 22 "format": "at-uri" 23 23 }, 24 - "sort": { 24 + "position": { 25 25 "type": "integer", 26 26 "default": 0 27 27 }
+125 -59
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 }, ··· 250 245 const rkey = params.rkey; 251 246 const gallery = getGallery(handle, rkey, ctx); 252 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} />); 253 256 }), 254 257 route("/onboard", (_req, _params, ctx) => { 255 258 requireAuth(ctx); ··· 575 578 576 579 return ctx.redirect(`/profile/${ctx.currentUser.handle}`); 577 580 }), 578 - route("/actions/sort-end", ["POST"], async (req, _params, ctx) => { 579 - const formData = await req.formData(); 580 - const items = formData.getAll("item") as string[]; 581 - console.log(items); 582 - return new Response(null, { status: 200 }); 583 - }), 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 + ), 584 631 ...photoUploadRoutes(), 585 632 ...avatarUploadRoutes(), 586 633 ], ··· 699 746 const { items: galleryItems } = ctx.indexService.getRecords< 700 747 WithBffMeta<GalleryItem> 701 748 >("social.grain.gallery.item", { 702 - orderBy: { field: "createdAt", direction: "asc" }, 749 + orderBy: { field: "position", direction: "asc" }, 703 750 where: [{ field: "gallery", in: galleryUris }], 704 751 }); 705 752 ··· 1055 1102 <script src="https://unpkg.com/hyperscript.org@0.9.14" /> 1056 1103 <script src="https://unpkg.com/sortablejs@1.15.6" /> 1057 1104 <style dangerouslySetInnerHTML={{ __html: CSS }} /> 1058 - <link rel="stylesheet" href={`/static/styles.css?${cssContentHash}`} /> 1105 + <link 1106 + rel="stylesheet" 1107 + href={`/static/styles.css?${staticFilesHash.get("styles.css")}`} 1108 + /> 1059 1109 <link rel="preconnect" href="https://fonts.googleapis.com" /> 1060 1110 <link 1061 1111 rel="preconnect" ··· 1074 1124 {scripts?.map((file) => ( 1075 1125 <script 1076 1126 key={file} 1077 - src={`/static/${file}?${staticJsFiles.get(file)}`} 1127 + src={`/static/${file}?${staticFilesHash.get(file)}`} 1078 1128 /> 1079 1129 ))} 1080 1130 </head> ··· 1660 1710 hx-target="#layout" 1661 1711 hx-swap="afterbegin" 1662 1712 > 1663 - Change Sort 1664 - </Button> 1665 - <Button 1666 - variant="primary" 1667 - class="self-start w-full sm:w-fit" 1668 - hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`} 1669 - hx-target="#layout" 1670 - hx-swap="afterbegin" 1671 - > 1672 1713 Edit 1673 1714 </Button> 1674 1715 <Button ··· 1680 1721 > 1681 1722 Add photos 1682 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> 1683 1733 <ShareGalleryButton gallery={gallery} /> 1684 1734 </div> 1685 1735 ) ··· 1697 1747 ) 1698 1748 : null} 1699 1749 </div> 1700 - <SortableGrid gallery={gallery} /> 1701 - { 1702 - /* <div 1703 1750 <div class="flex justify-end mb-2"> 1704 1751 <Button 1705 1752 id="justified-button" ··· 1817 1864 /> 1818 1865 )) 1819 1866 : null} 1820 - </div> */ 1821 - } 1867 + </div> 1822 1868 </div> 1823 1869 ); 1824 1870 } ··· 1858 1904 ); 1859 1905 } 1860 1906 1861 - function SortableGrid({ gallery }: Readonly<{ gallery: GalleryView }>) { 1907 + function GallerySortDialog({ gallery }: Readonly<{ gallery: GalleryView }>) { 1862 1908 return ( 1863 - <form 1864 - id="masonry-container" 1865 - class="sortable h-0 overflow-hidden relative mx-auto w-full" 1866 - _="on load or htmx:afterSettle call computeMasonry()" 1867 - // hx-post="/actions/sort-end" 1868 - // hx-trigger="end" 1869 - // hx-swap="none" 1870 - > 1871 - <div class="htmx-indicator">Updating...</div> 1872 - {gallery?.items?.filter(isPhotoView).map((item) => ( 1873 - <div 1874 - key={item.cid} 1875 - class="masonry-tile absolute cursor-pointer" 1876 - data-width={item.aspectRatio?.width} 1877 - data-height={item.aspectRatio?.height} 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" 1878 1918 > 1879 - <input type="hidden" name="item" value={item.uri} /> 1880 - <img 1881 - src={item.fullsize} 1882 - alt={item.alt} 1883 - class="w-full h-full object-cover" 1884 - /> 1885 - </div> 1886 - ))} 1887 - </form> 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> 1888 1954 ); 1889 1955 } 1890 1956
+1 -21
static/sortable.js
··· 1 1 htmx.onLoad(function (content) { 2 2 const sortables = content.querySelectorAll(".sortable"); 3 3 for (const sortable of sortables) { 4 - const sortableInstance = new Sortable(sortable, { 4 + new Sortable(sortable, { 5 5 animation: 150, 6 - swap: true, 7 - swapClass: "opacity-50", 8 - 9 - // Make the `.htmx-indicator` unsortable 10 - filter: ".htmx-indicator", 11 - onMove: function (evt) { 12 - console.log("onMove", evt); 13 - return evt.related.className.indexOf("htmx-indicator") === -1; 14 - }, 15 - 16 - // Disable sorting on the `end` event 17 - onEnd: function (_evt) { 18 - console.log("onEnd"); 19 - // this.option("disabled", true); 20 - }, 21 - }); 22 - 23 - // Re-enable sorting on the `htmx:afterSwap` event 24 - sortable.addEventListener("htmx:afterSwap", function () { 25 - // sortableInstance.option("disabled", false); 26 6 }); 27 7 } 28 8 });
+6 -3
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 } ··· 483 486 @supports (color: color-mix(in lab, red, red)) { 484 487 background-color: color-mix(in oklab, var(--color-black) 80%, transparent); 485 488 } 489 + } 490 + .bg-sky-500 { 491 + background-color: var(--color-sky-500); 486 492 } 487 493 .bg-zinc-100 { 488 494 background-color: var(--color-zinc-100); ··· 592 598 } 593 599 .lowercase { 594 600 text-transform: lowercase; 595 - } 596 - .opacity-50 { 597 - opacity: 50%; 598 601 } 599 602 .ring-sky-500 { 600 603 --tw-ring-color: var(--color-sky-500);