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

Update gallery upload/add photos flow, styles refresh (#3)

* wip

* update env vars and readme

* wip

* wip

* move login from bff, update nav with new styles, add confirm deletes to photos and galleries

* tidy

* remove unused

* update bff, update gallery info on add photos

* update lock

* flex wrap camera badges

* fix photo count formatting

authored by chadtmiller.com and committed by GitHub cbd9078d 00df6f4e

+2
.env.example
··· 2 2 BFF_PRIVATE_KEY_1= 3 3 BFF_PRIVATE_KEY_2= 4 4 BFF_PRIVATE_KEY_3= 5 + BFF_JETSTREAM_URL=wss://jetstream1.us-west.bsky.network 6 + PDS_HOST_URL=https://ansel.grainsocial.network 5 7 USE_CDN=true 6 8 7 9 # If running local infra
+2
README.md
··· 46 46 ```bash 47 47 # .env 48 48 BFF_DATABASE_URL=grain.db # SQLite db file 49 + BFF_JETSTREAM_URL=wss://jetstream1.us-west.bsky.network 50 + PDS_HOST_URL=https://ansel.grainsocial.network 49 51 USE_CDN=true # Use bsky cdn 50 52 ``` 51 53
+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.38", 5 + "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.39", 6 6 "@std/http": "jsr:@std/http@^1.0.17", 7 7 "@std/path": "jsr:@std/path@^1.0.9", 8 8 "@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+35 -8
deno.lock
··· 2 2 "version": "5", 3 3 "specifiers": { 4 4 "jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0", 5 - "jsr:@bigmoves/bff@0.3.0-beta.38": "0.3.0-beta.38", 5 + "jsr:@bigmoves/bff@0.3.0-beta.39": "0.3.0-beta.39", 6 6 "jsr:@deno/gfm@0.10": "0.10.0", 7 7 "jsr:@denosaurs/emoji@0.3": "0.3.1", 8 8 "jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1", ··· 33 33 "jsr:@std/streams@^1.0.10": "1.0.10", 34 34 "jsr:@std/testing@^1.0.11": "1.0.11", 35 35 "npm:@atproto-labs/handle-resolver-node@~0.1.14": "0.1.16", 36 + "npm:@atproto-labs/simple-store@~0.1.2": "0.1.2", 36 37 "npm:@atproto/api@~0.15.7": "0.15.15", 37 38 "npm:@atproto/common@~0.4.10": "0.4.11", 38 39 "npm:@atproto/identity@~0.4.7": "0.4.8", ··· 41 42 "npm:@atproto/lexicon@0.4.11": "0.4.11", 42 43 "npm:@atproto/lexicon@~0.4.11": "0.4.11", 43 44 "npm:@atproto/oauth-client@~0.3.13": "0.3.22", 45 + "npm:@atproto/oauth-types@~0.2.4": "0.2.8", 44 46 "npm:@atproto/syntax@0.4": "0.4.0", 45 47 "npm:@atproto/xrpc-server@*": "0.7.19", 48 + "npm:@atproto/xrpc-server@0.7.18": "0.7.18", 46 49 "npm:@tailwindcss/cli@*": "4.1.9", 47 50 "npm:@tailwindcss/cli@^4.0.12": "4.1.9", 48 51 "npm:@tailwindcss/cli@^4.1.3": "4.1.9", ··· 81 84 "integrity": "5c3ca124dd52eff51dace83790779ebe48c4b41559b799e16c8750bd415f2124", 82 85 "dependencies": [ 83 86 "npm:@atproto-labs/handle-resolver-node", 87 + "npm:@atproto-labs/simple-store", 84 88 "npm:@atproto/jwk", 85 89 "npm:@atproto/oauth-client", 90 + "npm:@atproto/oauth-types", 86 91 "npm:jose" 87 92 ] 88 93 }, 89 - "@bigmoves/bff@0.3.0-beta.38": { 90 - "integrity": "74b8b77f451a00b16c9a43ce776bdac87753429bf2d15bf6c81b5eda1e2f9e0e", 94 + "@bigmoves/bff@0.3.0-beta.39": { 95 + "integrity": "6841d6b04aa91f5c79b8530af1d2721c0f1bf5b4038270da528121f3ab57765f", 91 96 "dependencies": [ 92 97 "jsr:@bigmoves/atproto-oauth-client", 93 98 "jsr:@std/assert@^1.0.13", ··· 98 103 "npm:@atproto/api", 99 104 "npm:@atproto/common", 100 105 "npm:@atproto/identity", 106 + "npm:@atproto/lexicon@0.4.11", 101 107 "npm:@atproto/lexicon@~0.4.11", 102 108 "npm:@atproto/oauth-client", 103 109 "npm:@atproto/syntax", 110 + "npm:@atproto/xrpc-server@0.7.18", 104 111 "npm:clsx", 105 112 "npm:multiformats@^13.3.2", 106 113 "npm:preact", ··· 227 234 "dependencies": [ 228 235 "@atproto-labs/fetch", 229 236 "@atproto-labs/pipe", 230 - "@atproto-labs/simple-store", 237 + "@atproto-labs/simple-store@0.2.0", 231 238 "@atproto-labs/simple-store-memory", 232 239 "@atproto/did", 233 240 "zod" ··· 259 266 "@atproto-labs/handle-resolver@0.1.8": { 260 267 "integrity": "sha512-Y0ckccoCGDo/3g4thPkgp9QcORmc+qqEaCBCYCZYtfLIQp4775u22wd+4fyEyJP4DqoReKacninkICgRGfs3dQ==", 261 268 "dependencies": [ 262 - "@atproto-labs/simple-store", 269 + "@atproto-labs/simple-store@0.2.0", 263 270 "@atproto-labs/simple-store-memory", 264 271 "@atproto/did", 265 272 "zod" ··· 279 286 "@atproto-labs/simple-store-memory@0.1.3": { 280 287 "integrity": "sha512-jkitT9+AtU+0b28DoN92iURLaCt/q/q4yX8q6V+9LSwYlUTqKoj/5NFKvF7x6EBuG+gpUdlcycbH7e60gjOhRQ==", 281 288 "dependencies": [ 282 - "@atproto-labs/simple-store", 289 + "@atproto-labs/simple-store@0.2.0", 283 290 "lru-cache" 284 291 ] 285 292 }, 293 + "@atproto-labs/simple-store@0.1.2": { 294 + "integrity": "sha512-9vTNvyPPBs44tKVFht16wGlilW8u4wpEtKwLkWbuNEh3h9TTQ8zjVhEoGZh/v73G4Otr9JUOSIq+/5+8OZD2mQ==" 295 + }, 286 296 "@atproto-labs/simple-store@0.2.0": { 287 297 "integrity": "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA==" 288 298 }, ··· 371 381 "@atproto-labs/fetch", 372 382 "@atproto-labs/handle-resolver", 373 383 "@atproto-labs/identity-resolver", 374 - "@atproto-labs/simple-store", 384 + "@atproto-labs/simple-store@0.2.0", 375 385 "@atproto-labs/simple-store-memory", 376 386 "@atproto/did", 377 387 "@atproto/jwk@0.2.0", ··· 390 400 }, 391 401 "@atproto/syntax@0.4.0": { 392 402 "integrity": "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==" 403 + }, 404 + "@atproto/xrpc-server@0.7.18": { 405 + "integrity": "sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag==", 406 + "dependencies": [ 407 + "@atproto/common", 408 + "@atproto/crypto", 409 + "@atproto/lexicon", 410 + "@atproto/xrpc", 411 + "cbor-x", 412 + "express", 413 + "http-errors", 414 + "mime-types", 415 + "rate-limiter-flexible", 416 + "uint8arrays", 417 + "ws", 418 + "zod" 419 + ] 393 420 }, 394 421 "@atproto/xrpc-server@0.7.19": { 395 422 "integrity": "sha512-YSCl/tU2NDykgDYslFSOYCr96esUgDwncFiADKL59/fyIFPLoT0qY8Uq/budpxUh0qPzjow4HHgVWESOaOpUmA==", ··· 1883 1910 }, 1884 1911 "workspace": { 1885 1912 "dependencies": [ 1886 - "jsr:@bigmoves/bff@0.3.0-beta.38", 1913 + "jsr:@bigmoves/bff@0.3.0-beta.39", 1887 1914 "jsr:@std/http@^1.0.17", 1888 1915 "jsr:@std/path@^1.0.9", 1889 1916 "npm:@atproto/syntax@0.4",
+1
fly.toml
··· 13 13 BFF_DATABASE_URL = '/litefs/sqlite.db' 14 14 BFF_PORT = '8081' 15 15 BFF_PUBLIC_URL = 'https://grain.social' 16 + BFF_JETSTREAM_URL = 'wss://jetstream1.us-west.bsky.network' 16 17 GOATCOUNTER_URL = 'https://grain.goatcounter.com/count' 17 18 USE_CDN = 'true' 18 19 PDS_HOST_URL = 'https://ansel.grainsocial.network'
+1 -1
src/components/AltTextButton.tsx
··· 4 4 return ( 5 5 <button 6 6 type="button" 7 - 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" 7 + class="bg-zinc-950/50 dark:bg-zinc-950/50 py-[1px] px-[3px] absolute top-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" 8 8 hx-get={`/dialogs/photo/${new AtUri(photoUri).rkey}/alt`} 9 9 hx-trigger="click" 10 10 hx-target="#layout"
+1 -1
src/components/AvatarButton.tsx
··· 10 10 ? ( 11 11 <button 12 12 type="button" 13 - class="cursor-pointer" 13 + class="cursor-pointer w-fit" 14 14 hx-get={`/dialogs/avatar/${profile.handle}`} 15 15 hx-trigger="click" 16 16 hx-target="body"
+2 -2
src/components/AvatarDialog.tsx
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 2 import { Un$Typed } from "$lexicon/util.ts"; 3 - import { Dialog } from "@bigmoves/bff/components"; 4 3 import { ActorAvatar } from "./ActorAvatar.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 5 5 6 6 export function AvatarDialog({ 7 7 profile, 8 8 }: Readonly<{ profile: Un$Typed<ProfileView> }>) { 9 9 return ( 10 - <Dialog class="z-100"> 10 + <Dialog> 11 11 <Dialog.X /> 12 12 <div 13 13 class="w-[400px] h-[400px] flex flex-col p-4 z-10"
+10 -2
src/components/Breadcrumb.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + 1 3 type BreadcrumbItem = { 2 4 label: string; 3 5 href?: string; 4 6 }; 5 7 6 - export function Breadcrumb({ items }: Readonly<{ items: BreadcrumbItem[] }>) { 8 + export function Breadcrumb( 9 + { class: classProp, items }: Readonly< 10 + { class?: string; items: BreadcrumbItem[] } 11 + >, 12 + ) { 7 13 return ( 8 - <nav className="mb-4 text-sm text-zinc-500 dark:text-zinc-300"> 14 + <nav 15 + className={cn("mb-4 text-sm text-zinc-500 dark:text-zinc-300", classProp)} 16 + > 9 17 {items.map((item, idx) => ( 10 18 <> 11 19 {item.href
+33
src/components/Button.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + import { cloneElement, type JSX } from "preact"; 3 + 4 + export type ButtonProps = 5 + & JSX.ButtonHTMLAttributes<HTMLButtonElement> 6 + & Readonly<{ 7 + variant?: "primary" | "secondary" | "ghost" | "destructive" | "tab"; 8 + asChild?: boolean; 9 + }>; 10 + 11 + export function Button(props: ButtonProps): JSX.Element { 12 + const { variant, class: classProp, asChild, children, ...rest } = props; 13 + const className = cn( 14 + "grain-btn", 15 + variant === "primary" && "grain-btn-primary", 16 + variant === "secondary" && "grain-btn-secondary", 17 + variant === "ghost" && "grain-btn-ghost", 18 + variant === "destructive" && "grain-btn-destructive", 19 + variant === "tab" && "grain-btn-tab", 20 + classProp, 21 + ); 22 + if ( 23 + asChild && children && typeof children === "object" && children !== null && 24 + "type" in children 25 + ) { 26 + return cloneElement(children, { 27 + ...rest, 28 + ...children.props, 29 + class: cn(className, children.props.class), 30 + }); 31 + } 32 + return <button class={className} {...rest}>{children}</button>; 33 + }
+2 -2
src/components/CameraBadges.tsx
··· 7 7 ) { 8 8 if (cameras.length === 0) return null; 9 9 return ( 10 - <div class={cn("flex gap-1", classProp)} id="camera-badges"> 10 + <div class={cn("flex flex-wrap gap-1", classProp)} id="camera-badges"> 11 11 {cameras.sort().map((camera) => ( 12 - <span class="text-xs font-semibold bg-zinc-100 dark:bg-zinc-800 w-fit px-1"> 12 + <span class="text-xs font-semibold bg-zinc-100 dark:bg-zinc-800 w-fit px-2 py-1 rounded-full"> 13 13 📷 {camera} 14 14 </span> 15 15 ))}
+4 -3
src/components/CreateAccountDialog.tsx
··· 1 1 import { OAUTH_ROUTES } from "@bigmoves/bff"; 2 - import { Button, Dialog } from "@bigmoves/bff/components"; 3 2 import { PDS_HOST_URL } from "../env.ts"; 3 + import { Button } from "./Button.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 4 5 5 6 export function CreateAccountDialog({}: Readonly<{}>) { 6 7 return ( 7 - <Dialog id="photo-alt-dialog" class="z-100"> 8 - <Dialog.Content class="dark:bg-zinc-950 relative"> 8 + <Dialog id="photo-alt-dialog"> 9 + <Dialog.Content> 9 10 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 10 11 <Dialog.Title>Choose your handle</Dialog.Title> 11 12 <div className="flex flex-col space-y-4 my-10">
+133
src/components/Dialog.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + import type { FunctionalComponent, JSX } from "preact"; 3 + import { Button, ButtonProps } from "./Button.tsx"; 4 + 5 + type DialogProps = JSX.HTMLAttributes<HTMLDivElement> & { _?: string } & { 6 + children: preact.ComponentChildren; 7 + }; 8 + 9 + type DialogContentProps = JSX.HTMLAttributes<HTMLDivElement> & { 10 + children: preact.ComponentChildren; 11 + }; 12 + 13 + type DialogTitleProps = { 14 + children: preact.ComponentChildren; 15 + }; 16 + 17 + type DialogCloseProps = JSX.HTMLAttributes<HTMLButtonElement> & ButtonProps & { 18 + children: preact.ComponentChildren; 19 + }; 20 + 21 + type DialogXProps = JSX.HTMLAttributes<HTMLButtonElement>; 22 + 23 + const _closeOnClick = "on click trigger closeDialog"; 24 + 25 + const Dialog: FunctionalComponent<DialogProps> & { 26 + Content: FunctionalComponent<DialogContentProps>; 27 + Title: FunctionalComponent<DialogTitleProps>; 28 + Close: FunctionalComponent<DialogCloseProps>; 29 + X: FunctionalComponent<DialogXProps>; 30 + _closeOnClick: string; 31 + } = ({ children, class: classProp, _ = "", ...props }) => { 32 + return ( 33 + <div 34 + class={cn( 35 + "fixed top-0 bottom-0 right-0 left-0 flex items-center justify-center z-100", 36 + classProp, 37 + )} 38 + {...{ 39 + _: `on closeDialog 40 + remove me 41 + remove .tw:pointer-events-none from document.body 42 + remove [@data-scroll-locked] from document.body 43 + on keyup[key is 'Escape'] from <body/> trigger closeDialog 44 + init 45 + add .tw:pointer-events-none to document.body 46 + add .tw:pointer-events-auto to me 47 + add [@data-scroll-locked=true] to document.body 48 + ${_}`, 49 + }} 50 + {...props} 51 + > 52 + <div 53 + class="absolute top-0 left-0 right-0 bottom-0 bg-black/80" 54 + {...{ 55 + _: _closeOnClick, 56 + }} 57 + /> 58 + {children} 59 + </div> 60 + ); 61 + }; 62 + 63 + const DialogContent: FunctionalComponent<DialogContentProps> = ( 64 + { children, class: classProp, ...props }, 65 + ) => { 66 + return ( 67 + <div 68 + class={cn( 69 + "w-[400px] bg-white dark:bg-zinc-900 rounded-md flex flex-col p-4 max-h-[calc(100vh-100px)] overflow-y-auto z-20 relative", 70 + classProp, 71 + )} 72 + {...props} 73 + > 74 + {children} 75 + </div> 76 + ); 77 + }; 78 + 79 + const DialogTitle: FunctionalComponent<DialogTitleProps> = ({ children }) => { 80 + return ( 81 + <h1 class="text-lg font-semibold text-center w-full mb-2"> 82 + {children} 83 + </h1> 84 + ); 85 + }; 86 + 87 + const DialogX: FunctionalComponent<DialogXProps> = ({ 88 + class: classProp, 89 + }) => { 90 + return ( 91 + <button 92 + type="button" 93 + class={cn( 94 + "absolute top-4 right-4 h-4 w-4 cursor-pointer z-30 fill-white", 95 + classProp, 96 + )} 97 + {...{ 98 + _: _closeOnClick, 99 + }} 100 + > 101 + <svg 102 + xmlns="http://www.w3.org/2000/svg" 103 + viewBox="0 0 384 512" 104 + > 105 + {/* <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->*/} 106 + <path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z" /> 107 + </svg> 108 + </button> 109 + ); 110 + }; 111 + 112 + const DialogClose: FunctionalComponent<DialogCloseProps> = ( 113 + { children, ...props }, 114 + ) => { 115 + return ( 116 + <Button 117 + {...{ 118 + _: _closeOnClick, 119 + }} 120 + {...props} 121 + > 122 + {children} 123 + </Button> 124 + ); 125 + }; 126 + 127 + Dialog.Content = DialogContent; 128 + Dialog.Title = DialogTitle; 129 + Dialog.Close = DialogClose; 130 + Dialog.X = DialogX; 131 + Dialog._closeOnClick = _closeOnClick; 132 + 133 + export { Dialog };
+91
src/components/EditGalleryDialog.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { Button } from "./Button.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 5 + 6 + export function EditGalleryDialog({ gallery }: Readonly<{ 7 + gallery: GalleryView; 8 + }>) { 9 + const rkey = new AtUri(gallery.uri).rkey; 10 + return ( 11 + <Dialog> 12 + <Dialog.Content class="gap-4"> 13 + <Dialog.Title>Edit gallery</Dialog.Title> 14 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 15 + 16 + <ul class="divide-y divide-zinc-200 dark:divide-zinc-800 border-t border-b border-zinc-200 dark:border-zinc-800"> 17 + <li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800"> 18 + <button 19 + type="button" 20 + class="flex flex-col justify-start items-start text-left w-full px-2 py-4 cursor-pointer" 21 + hx-get={`/dialogs/gallery/${rkey}/edit`} 22 + hx-target="#dialog-target" 23 + hx-swap="innerHTML" 24 + > 25 + Edit details 26 + <div class="text-sm text-zinc-600 dark:text-zinc-500"> 27 + Update title, description, etc 28 + </div> 29 + </button> 30 + </li> 31 + <li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800"> 32 + <button 33 + type="button" 34 + class="flex flex-col justify-start items-start text-left w-full px-2 py-4 cursor-pointer" 35 + hx-get={`/dialogs/gallery/${rkey}/photos`} 36 + hx-target="#dialog-target" 37 + hx-swap="innerHTML" 38 + > 39 + Edit photos 40 + <div class="text-sm text-zinc-600 dark:text-zinc-500"> 41 + Upload photos, add from library, or remove photos 42 + </div> 43 + </button> 44 + </li> 45 + <li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800"> 46 + <button 47 + type="button" 48 + hx-get={`/dialogs/gallery/${rkey}/sort`} 49 + hx-target="#dialog-target" 50 + hx-swap="innerHTML" 51 + class="flex justify-between items-center text-left w-full px-2 py-4 cursor-pointer" 52 + > 53 + Change sort order 54 + </button> 55 + </li> 56 + </ul> 57 + <form 58 + id="delete-form" 59 + hx-post={`/actions/gallery/delete`} 60 + hx-confirm="Are you sure you want to delete this gallery? This action cannot be undone. Photos in the gallery will not be deleted." 61 + > 62 + <input type="hidden" name="uri" value={gallery?.uri} /> 63 + </form> 64 + <Button 65 + variant="destructive" 66 + form="delete-form" 67 + > 68 + Delete gallery 69 + </Button> 70 + </Dialog.Content> 71 + </Dialog> 72 + ); 73 + } 74 + 75 + export function EditGalleryButton({ gallery }: Readonly<{ 76 + gallery: GalleryView; 77 + }>) { 78 + const rkey = new AtUri(gallery.uri).rkey; 79 + return ( 80 + <Button 81 + type="button" 82 + variant="primary" 83 + hx-get={`/dialogs/gallery/${rkey}`} 84 + hx-trigger="click" 85 + hx-target="#dialog-target" 86 + hx-swap="innerHTML" 87 + > 88 + Edit gallery 89 + </Button> 90 + ); 91 + }
+4 -3
src/components/ExifInfoDialog.tsx
··· 1 - import { Dialog } from "@bigmoves/bff/components"; 1 + import { Dialog } from "./Dialog.tsx"; 2 2 3 3 export function ExifInfoDialog() { 4 4 return ( 5 - <Dialog class="z-100"> 6 - <Dialog.Content class="dark:bg-zinc-950 relative"> 5 + <Dialog class="z-101"> 6 + <Dialog.Content class="flex flex-col gap-4"> 7 7 <Dialog.Title>EXIF Info</Dialog.Title> 8 8 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 9 9 <div class="text-sm mt-2"> ··· 70 70 . 71 71 </p> 72 72 </div> 73 + <Dialog.Close variant="secondary">Close</Dialog.Close> 73 74 </Dialog.Content> 74 75 </Dialog> 75 76 );
+2 -2
src/components/ExifOverlayDialog.tsx
··· 1 1 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 - import { Dialog } from "@bigmoves/bff/components"; 3 2 import { getOrderedExifData } from "../lib/photo.ts"; 3 + import { Dialog } from "./Dialog.tsx"; 4 4 5 5 export function ExifOverlayDialog({ 6 6 photo, ··· 10 10 return ( 11 11 <Dialog class="z-101"> 12 12 <Dialog.Content 13 - class="bg-transparent text-zinc-50 relative" 13 + class="bg-transparent! text-zinc-50 relative" 14 14 _={Dialog._closeOnClick} 15 15 > 16 16 <Dialog.Title>Camera Settings</Dialog.Title>
+2 -1
src/components/FavoriteButton.tsx
··· 1 1 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 2 import { AtUri } from "@atproto/syntax"; 3 3 import { WithBffMeta } from "@bigmoves/bff"; 4 - import { Button, cn } from "@bigmoves/bff/components"; 4 + import { cn } from "@bigmoves/bff/components"; 5 + import { Button } from "./Button.tsx"; 5 6 6 7 export function FavoriteButton({ 7 8 currentUserDid,
+2 -1
src/components/FollowButton.tsx
··· 1 1 import { AtUri } from "@atproto/syntax"; 2 - import { Button, cn } from "@bigmoves/bff/components"; 2 + import { cn } from "@bigmoves/bff/components"; 3 + import { Button } from "./Button.tsx"; 3 4 4 5 export function FollowButton({ 5 6 followeeDid,
+10 -24
src/components/GalleryCreateEditDialog.tsx src/components/GalleryDetailsDialog.tsx
··· 1 1 import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 2 2 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 3 - import { Button, Dialog, Input, Textarea } from "@bigmoves/bff/components"; 3 + import { Button } from "./Button.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 5 + import { Input } from "./Input.tsx"; 6 + import { Label } from "./Label.tsx"; 7 + import { Textarea } from "./Textarea.tsx"; 4 8 5 - export function GalleryCreateEditDialog({ 9 + export function GalleryDetailsDialog({ 6 10 gallery, 7 11 }: Readonly<{ gallery?: GalleryView | null }>) { 8 12 return ( 9 - <Dialog id="gallery-dialog" class="z-100"> 10 - <Dialog.Content class="dark:bg-zinc-950 relative"> 13 + <Dialog id="gallery-dialog"> 14 + <Dialog.Content> 11 15 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 12 16 <Dialog.Title> 13 17 {gallery ? "Edit gallery" : "Create a new gallery"} ··· 24 28 alert('Error: ' + event.detail.xhr.responseText)" 25 29 > 26 30 <div class="mb-4 relative"> 27 - <label htmlFor="title">Gallery name</label> 31 + <Label htmlFor="title">Gallery name</Label> 28 32 <Input 29 33 type="text" 30 34 id="title" ··· 36 40 /> 37 41 </div> 38 42 <div class="mb-2 relative"> 39 - <label htmlFor="description">Description</label> 43 + <Label htmlFor="description">Description</Label> 40 44 <Textarea 41 45 id="description" 42 46 name="description" ··· 55 59 class="hidden" 56 60 /> 57 61 </div> 58 - <form 59 - id="delete-form" 60 - hx-post={`/actions/gallery/delete?uri=${gallery?.uri}`} 61 - > 62 - <input type="hidden" name="uri" value={gallery?.uri} /> 63 - </form> 64 62 <div class="flex flex-col gap-2 mt-2"> 65 63 <Button 66 64 variant="primary" ··· 70 68 > 71 69 {gallery ? "Update gallery" : "Create gallery"} 72 70 </Button> 73 - {gallery 74 - ? ( 75 - <Button 76 - variant="destructive" 77 - form="delete-form" 78 - type="submit" 79 - class="w-full" 80 - > 81 - Delete gallery 82 - </Button> 83 - ) 84 - : null} 85 71 <Button 86 72 variant="secondary" 87 73 type="button"
+84
src/components/GalleryEditPhotosDialog.tsx
··· 1 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 + import { $Typed } from "$lexicon/util.ts"; 3 + import { Button } from "./Button.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 5 + import { LibaryPhotoSelectDialogButton } from "./LibraryPhotoSelectDialog.tsx"; 6 + import { PhotoSelectButton } from "./PhotoSelectButton.tsx"; 7 + 8 + export function GalleryEditPhotosDialog({ 9 + galleryUri, 10 + photos, 11 + }: Readonly<{ 12 + galleryUri: string; 13 + photos: $Typed<PhotoView>[]; 14 + }>) { 15 + return ( 16 + <Dialog id="photo-select-dialog"> 17 + <Dialog.Content class="flex flex-col gap-4"> 18 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 19 + <Dialog.Title>Edit photos</Dialog.Title> 20 + <div class="flex flex-col gap-4"> 21 + <form 22 + class="w-full flex flex-col gap-4" 23 + hx-encoding="multipart/form-data" 24 + _="on change from #file-input call Grain.galleryPhotosDialog.uploadPhotos(me)" 25 + > 26 + <input hidden name="page" value="gallery" /> 27 + <input hidden name="galleryUri" value={galleryUri} /> 28 + <Button variant="primary" class="w-full" asChild> 29 + <label> 30 + <i class="fa-solid fa-cloud-arrow-up mr-2" /> 31 + Upload photos 32 + <input 33 + id="file-input" 34 + class="hidden" 35 + type="file" 36 + name="files" 37 + multiple 38 + accept="image/*" 39 + /> 40 + </label> 41 + </Button> 42 + 43 + <label class="block gap-2"> 44 + <input 45 + id="parse-exif" 46 + type="checkbox" 47 + name="parseExif" 48 + class="mr-2 accent-sky-600" 49 + checked 50 + /> 51 + Include image metadata (EXIF) 52 + <button 53 + type="button" 54 + hx-get="/dialogs/exif-info" 55 + hx-target="#layout" 56 + hx-swap="afterbegin" 57 + class="cursor-pointer" 58 + > 59 + <i class="fa fa-info-circle ml-1" /> 60 + </button> 61 + </label> 62 + </form> 63 + <LibaryPhotoSelectDialogButton galleryUri={galleryUri} /> 64 + </div> 65 + <div class="flex-1 overflow-y-auto"> 66 + <div id="image-preview" class="grid grid-cols-3 gap-2"> 67 + {photos.length 68 + ? ( 69 + photos.map((photo) => ( 70 + <PhotoSelectButton 71 + key={photo.cid} 72 + galleryUri={galleryUri} 73 + photo={photo} 74 + /> 75 + )) 76 + ) 77 + : null} 78 + </div> 79 + </div> 80 + <Dialog.Close variant="secondary" class="w-full">Close</Dialog.Close> 81 + </Dialog.Content> 82 + </Dialog> 83 + ); 84 + }
+1 -1
src/components/GalleryLayout.tsx
··· 1 1 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 2 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 3 import { AtUri } from "@atproto/syntax"; 4 - import { Button } from "@bigmoves/bff/components"; 5 4 import { ComponentChildren } from "preact"; 6 5 import { photoDialogLink } from "../utils.ts"; 6 + import { Button } from "./Button.tsx"; 7 7 import { JustifiedSvg } from "./JustifiedSvg.tsx"; 8 8 import { MasonrySvg } from "./MasonrySvg.tsx"; 9 9
+13 -28
src/components/GalleryPage.tsx
··· 3 3 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 4 4 import { AtUri } from "@atproto/syntax"; 5 5 import { WithBffMeta } from "@bigmoves/bff"; 6 - import { Button } from "@bigmoves/bff/components"; 7 6 import { ModerationDecsion } from "../lib/moderation.ts"; 7 + import { EditGalleryButton } from "./EditGalleryDialog.tsx"; 8 8 import { FavoriteButton } from "./FavoriteButton.tsx"; 9 9 import { GalleryInfo } from "./GalleryInfo.tsx"; 10 10 import { GalleryLayout } from "./GalleryLayout.tsx"; ··· 27 27 const galleryItems = gallery.items?.filter(isPhotoView) ?? []; 28 28 return ( 29 29 <div class="px-4" id="gallery-page"> 30 + <div id="dialog-target" /> 30 31 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4 mb-2"> 31 32 <GalleryInfo gallery={gallery} /> 32 33 {isLoggedIn && isCreator 33 34 ? ( 34 35 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end"> 35 - <Button 36 - variant="primary" 37 - class="self-start w-full sm:w-fit whitespace-nowrap" 38 - hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`} 39 - hx-target="#layout" 40 - hx-swap="afterbegin" 41 - > 42 - Edit 43 - </Button> 44 - <Button 45 - hx-get={`/dialogs/photo-select/${new AtUri(gallery.uri).rkey}`} 46 - hx-target="#layout" 47 - hx-swap="afterbegin" 48 - variant="primary" 49 - class="self-start w-full sm:w-fit whitespace-nowrap" 50 - > 51 - Add photos 52 - </Button> 53 - <Button 54 - variant="primary" 55 - class="self-start w-full sm:w-fit whitespace-nowrap" 56 - hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/sort`} 57 - hx-target="#layout" 58 - hx-swap="afterbegin" 59 - > 60 - Sort order 61 - </Button> 36 + <EditGalleryButton gallery={gallery} /> 62 37 <ShareGalleryButton gallery={gallery} /> 63 38 <FavoriteButton 64 39 currentUserDid={currentUserDid} ··· 81 56 ) 82 57 : null} 83 58 </div> 59 + {isLoggedIn && isCreator && gallery.items?.length === 0 60 + ? ( 61 + <div 62 + hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/photos`} 63 + hx-trigger="load" 64 + hx-target="#dialog-target" 65 + hx-swap="innerHTML" 66 + /> 67 + ) 68 + : null} 84 69 { 85 70 <ModerationWrapper moderationDecision={modDecision} class="mb-2"> 86 71 <GalleryLayout
+1 -1
src/components/GalleryPreviewLink.tsx
··· 9 9 gallery, 10 10 size = "default", 11 11 }: Readonly<{ gallery: Un$Typed<GalleryView>; size?: "small" | "default" }>) { 12 - const gap = size === "small" ? "gap-1" : "gap-2"; 12 + const gap = size === "small" ? "gap-1" : "gap-1"; 13 13 return ( 14 14 <a 15 15 href={galleryLink(
+129
src/components/GallerySelectDialog.tsx
··· 1 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 2 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { Button } from "./Button.tsx"; 5 + import { Dialog } from "./Dialog.tsx"; 6 + import { Input } from "./Input.tsx"; 7 + 8 + export function GallerySelectDialog( 9 + { photoUri, userDid, galleries }: Readonly< 10 + { 11 + photoUri?: string; 12 + userDid: string; 13 + galleries: GalleryView[]; 14 + } 15 + >, 16 + ) { 17 + return ( 18 + <Dialog id="gallery-select-dialog"> 19 + <Dialog.Content class="min-h-[calc(100vh-100px)] overflow-hidden flex flex-col gap-4"> 20 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 21 + <Dialog.Title> 22 + {photoUri ? "Add to gallery" : "Select gallery"} 23 + </Dialog.Title> 24 + 25 + <form> 26 + <Input 27 + type="text" 28 + name="q" 29 + placeholder="Enter gallery name or select from the list" 30 + hx-get={`/dialogs/gallery/${userDid}/select`} 31 + hx-target="#search-results" 32 + hx-trigger="input changed delay:500ms, keyup[key=='Enter']" 33 + hx-swap="innerHTML" 34 + autoFocus 35 + /> 36 + </form> 37 + 38 + <div id="search-results" class="flex-1 overflow-y-auto"> 39 + <GallerySelectDialogSearchResults 40 + photoUri={photoUri} 41 + galleries={galleries} 42 + /> 43 + </div> 44 + <Dialog.Close variant="secondary"> 45 + Close 46 + </Dialog.Close> 47 + </Dialog.Content> 48 + </Dialog> 49 + ); 50 + } 51 + 52 + export function GallerySelectDialogSearchResults( 53 + { photoUri, galleries }: { 54 + photoUri?: string; 55 + galleries: GalleryView[]; 56 + }, 57 + ) { 58 + return ( 59 + galleries.length > 0 60 + ? ( 61 + <ul class="divide-zinc-200 dark:divide-zinc-800 divide-y"> 62 + {galleries.map((gallery) => ( 63 + <li 64 + key={gallery.cid} 65 + class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800" 66 + > 67 + {photoUri 68 + ? ( 69 + <button 70 + type="button" 71 + hx-put={addToGalleryActionLink(photoUri, gallery.uri)} 72 + hx-swap="none" 73 + class="block text-left w-full px-2 py-4" 74 + > 75 + {(gallery.record as Gallery).title} 76 + <div class="text-sm text-zinc-600 dark:text-zinc-500"> 77 + {(gallery.record as Gallery).description} 78 + </div> 79 + </button> 80 + ) 81 + : ( 82 + <a 83 + href={uploadPageLink(gallery.uri)} 84 + class="block w-full px-2 py-4" 85 + > 86 + {(gallery.record as Gallery).title} 87 + <div class="text-sm text-zinc-600 dark:text-zinc-500"> 88 + {(gallery.record as Gallery).description} 89 + </div> 90 + </a> 91 + )} 92 + </li> 93 + ))} 94 + </ul> 95 + ) 96 + : <p>No galleries found.</p> 97 + ); 98 + } 99 + 100 + function addToGalleryActionLink(photoUri: string, galleryUri: string) { 101 + const photoRKey = new AtUri(photoUri).rkey; 102 + const galleryRkey = new AtUri(galleryUri).rkey; 103 + return `/actions/gallery/${galleryRkey}/add-photo/${photoRKey}?page=upload`; 104 + } 105 + 106 + function uploadPageLink(galleryUri: string) { 107 + const rkey = new AtUri(galleryUri).rkey; 108 + return `/upload?gallery=${rkey}`; 109 + } 110 + 111 + export function GallerySelectDialogButton( 112 + { userDid }: Readonly<{ userDid: string }>, 113 + ) { 114 + return ( 115 + <Button 116 + type="button" 117 + variant="secondary" 118 + class="w-full sm:w-fit" 119 + hx-get={`/dialogs/gallery/${userDid}/select`} 120 + hx-trigger="click" 121 + hx-target="#layout" 122 + hx-swap="afterbegin" 123 + _="on click halt" 124 + > 125 + <i class="fa fa-filter mr-2" /> 126 + Filter by gallery 127 + </Button> 128 + ); 129 + }
+4 -3
src/components/GallerySortDialog.tsx
··· 1 1 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 2 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 3 import { AtUri } from "@atproto/syntax"; 4 - import { Button, Dialog } from "@bigmoves/bff/components"; 4 + import { Button } from "./Button.tsx"; 5 + import { Dialog } from "./Dialog.tsx"; 5 6 6 7 export function GallerySortDialog( 7 8 { gallery }: Readonly<{ gallery: GalleryView }>, 8 9 ) { 9 10 return ( 10 - <Dialog class="z-100" id="gallery-sort-dialog"> 11 - <Dialog.Content class="dark:bg-zinc-950 relative"> 11 + <Dialog id="gallery-sort-dialog"> 12 + <Dialog.Content> 12 13 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 13 14 <Dialog.Title>Sort gallery</Dialog.Title> 14 15 <p class="my-2 text-center">Drag photos to rearrange</p>
+13
src/components/Input.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + import type { JSX } from "preact"; 3 + 4 + export type InputProps = JSX.InputHTMLAttributes<HTMLInputElement>; 5 + 6 + export function Input(props: InputProps): JSX.Element { 7 + const { class: classProp, ...rest } = props; 8 + const className = cn( 9 + "grain-input", 10 + classProp, 11 + ); 12 + return <input class={className} {...rest} />; 13 + }
+13
src/components/Label.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + import type { JSX } from "preact"; 3 + 4 + export type LabelProps = JSX.LabelHTMLAttributes<HTMLLabelElement>; 5 + 6 + export function Label(props: LabelProps): JSX.Element { 7 + const { class: classProp, ...rest } = props; 8 + const className = cn( 9 + "grain-label", 10 + classProp, 11 + ); 12 + return <label class={className} {...rest} />; 13 + }
+3 -3
src/components/LabelDefinitionDialog.tsx
··· 1 1 import { LabelValueDefinition } from "$lexicon/types/com/atproto/label/defs.ts"; 2 - import { Dialog } from "@bigmoves/bff/components"; 3 2 import { profileLink } from "../utils.ts"; 3 + import { Dialog } from "./Dialog.tsx"; 4 4 5 5 export function LabelDefinitionDialog({ 6 6 labelValueDefinition, ··· 13 13 (locale) => locale.lang === "en", 14 14 ); 15 15 return ( 16 - <Dialog id="mod-decision-dialog" class="z-100"> 17 - <Dialog.Content class="dark:bg-zinc-950 relative gap-2"> 16 + <Dialog id="mod-decision-dialog"> 17 + <Dialog.Content class="gap-2"> 18 18 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 19 19 <Dialog.Title>{enLocale?.name}</Dialog.Title> 20 20 <p>{enLocale?.description}</p>
+8 -8
src/components/Layout.tsx
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 2 import { Un$Typed } from "$lexicon/util.ts"; 3 - import { Button, cn } from "@bigmoves/bff/components"; 3 + import { cn } from "@bigmoves/bff/components"; 4 4 import type { FunctionalComponent, JSX } from "preact"; 5 5 import { ActorAvatar } from "./ActorAvatar.tsx"; 6 + import { Button } from "./Button.tsx"; 6 7 7 8 type LayoutProps = JSX.HTMLAttributes<HTMLDivElement> & { 8 9 children: preact.ComponentChildren; ··· 77 78 <div class="flex space-x-2"> 78 79 {profile 79 80 ? ( 80 - <div class="flex items-center ts:space-x-1 sm:space-x-2"> 81 + <div class="flex items-center space-x-1 sm:space-x-2"> 81 82 <form hx-post="/logout" hx-swap="none" class="inline"> 82 83 <Button type="submit" variant="secondary">Sign out</Button> 83 84 </form> 84 85 <Button 85 86 asChild 86 - variant="secondary" 87 - class="relative pl-2" 87 + variant="ghost" 88 88 > 89 89 <a href="/explore"> 90 90 <i class="fas fa-search text-zinc-950 dark:text-zinc-50" /> ··· 92 92 </Button> 93 93 <Button 94 94 asChild 95 - variant="secondary" 96 - class="relative pl-2" 95 + variant="ghost" 96 + class="relative" 97 97 > 98 98 <a href="/notifications"> 99 99 <i class="fas fa-bell text-zinc-950 dark:text-zinc-50" /> ··· 110 110 </div> 111 111 ) 112 112 : ( 113 - <div class="flex items-center space-x-4"> 113 + <div class="flex items-center space-x-2"> 114 114 <Button 115 115 variant="secondary" 116 116 hx-get={`/dialogs/create-account`} ··· 120 120 > 121 121 Create account 122 122 </Button> 123 - <Button variant="secondary" asChild> 123 + <Button variant="primary" asChild> 124 124 <a href="/login"> 125 125 Sign in 126 126 </a>
+123
src/components/LibraryPhotoSelectDialog.tsx
··· 1 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 + import { $Typed } from "$lexicon/util.ts"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { Button } from "./Button.tsx"; 5 + import { Dialog } from "./Dialog.tsx"; 6 + 7 + export function LibraryPhotoSelectDialog({ 8 + galleryUri, 9 + photos, 10 + }: Readonly<{ 11 + galleryUri: string; 12 + photos: $Typed<PhotoView>[]; 13 + }>) { 14 + const rkey = new AtUri(galleryUri).rkey; 15 + return ( 16 + <Dialog id="photo-select-dialog" class="z-101"> 17 + <Dialog.Content class="flex flex-col gap-4"> 18 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 19 + <Dialog.Title>My library</Dialog.Title> 20 + 21 + <form 22 + id="photo-select-form" 23 + hx-put={`/actions/gallery/${rkey}/add-photos`} 24 + hx-target="#dialog-target" 25 + hx-swap="innerHTML" 26 + class="flex-1 overflow-y-auto" 27 + > 28 + {photos.length 29 + ? ( 30 + <div class="grid grid-cols-3 gap-2"> 31 + {photos.map((photo) => ( 32 + <PhotoItem 33 + key={photo.cid} 34 + photo={photo} 35 + /> 36 + ))} 37 + </div> 38 + ) 39 + : ( 40 + <div class="flex justify-center items-center my-30 h-full"> 41 + <p>No photos yet.</p> 42 + </div> 43 + )} 44 + </form> 45 + 46 + <div 47 + id="photo-select-overlay" 48 + class="w-full bg-white dark:bg-zinc-900 flex justify-between items-center z-102" 49 + _="on load set my.count to 0" 50 + > 51 + <span id="selected-count">0 selected</span> 52 + <Button 53 + type="submit" 54 + form="photo-select-form" 55 + variant="primary" 56 + > 57 + Add to gallery 58 + </Button> 59 + </div> 60 + 61 + <Dialog.Close variant="secondary" class="w-full">Close</Dialog.Close> 62 + </Dialog.Content> 63 + </Dialog> 64 + ); 65 + } 66 + 67 + export function LibaryPhotoSelectDialogButton({ galleryUri }: Readonly<{ 68 + galleryUri: string; 69 + }>) { 70 + const rkey = new AtUri(galleryUri).rkey; 71 + return ( 72 + <Button 73 + type="button" 74 + variant="secondary" 75 + hx-get={`/dialogs/gallery/${rkey}/library`} 76 + hx-trigger="click" 77 + hx-target="#dialog-target" 78 + hx-swap="innerHTML" 79 + > 80 + <i class="fa-solid fa-plus mr-2" /> 81 + Add from library 82 + </Button> 83 + ); 84 + } 85 + 86 + export function PhotoItem({ 87 + photo, 88 + }: Readonly<{ 89 + photo: PhotoView; 90 + }>) { 91 + return ( 92 + <button 93 + type="button" 94 + class="group relative aspect-square cursor-pointer" 95 + _=" 96 + on click 97 + set checkbox to me.querySelector('input[type=checkbox]') 98 + set checkbox.checked to not checkbox.checked 99 + trigger change on checkbox 100 + " 101 + > 102 + <input 103 + type="checkbox" 104 + name="photoUri" 105 + value={photo.uri} 106 + class="peer absolute top-2 left-2 z-30 w-5 h-5 accent-sky-600" 107 + _=" 108 + on change 109 + set checkedCount to my.closest('form') or document 110 + then set checkedInputs to checkedCount.querySelectorAll('input[type=checkbox]:checked') 111 + then set count to checkedInputs.length 112 + then set #selected-count's innerText to `${count} selected`" 113 + /> 114 + 115 + <img 116 + src={photo.fullsize} 117 + alt={photo.alt} 118 + loading="lazy" 119 + class="w-full h-full object-cover transition-opacity duration-200 peer-checked:opacity-50" 120 + /> 121 + </button> 122 + ); 123 + }
+75
src/components/Login.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + import type { JSX } from "preact"; 3 + import { Button } from "./Button.tsx"; 4 + import { Input } from "./Input.tsx"; 5 + 6 + export type LoginProps = 7 + & JSX.HTMLAttributes<HTMLFormElement> 8 + & Readonly<{ 9 + inputPlaceholder?: string; 10 + submitText?: string; 11 + infoText?: string; 12 + error?: string; 13 + errorClass?: string; 14 + infoClass?: string; 15 + }>; 16 + 17 + export function Login( 18 + { 19 + inputPlaceholder = "Handle (e.g., user.bsky.social)", 20 + submitText = "Login with Bluesky", 21 + infoText = "", 22 + error, 23 + errorClass, 24 + infoClass, 25 + ...rest 26 + }: LoginProps, 27 + ): JSX.Element { 28 + return ( 29 + <form 30 + id="login-form" 31 + hx-post="/oauth/login" 32 + hx-target="#login-form" 33 + hx-swap="outerHTML" 34 + {...rest} 35 + class={cn( 36 + "tw:mx-4 tw:sm:mx-0 tw:w-full tw:sm:max-w-[300px] tw:space-y-2", 37 + rest.class, 38 + )} 39 + > 40 + <div> 41 + <label htmlFor="handle" class="tw:sr-only"> 42 + Handle 43 + </label> 44 + <Input 45 + id="handle" 46 + class="bg-white text-zinc-900" 47 + placeholder={inputPlaceholder} 48 + name="handle" 49 + /> 50 + </div> 51 + <Button 52 + variant="primary" 53 + id="submit" 54 + type="submit" 55 + class="tw:w-full" 56 + > 57 + {submitText} 58 + </Button> 59 + {infoText && ( 60 + <div class={cn("tw:text-sm tw:text", infoClass)}> 61 + {infoText} 62 + </div> 63 + )} 64 + <div className="tw:h-4"> 65 + {error 66 + ? ( 67 + <div className={cn("tw:text-sm tw:font-mono", errorClass)}> 68 + {error} 69 + </div> 70 + ) 71 + : null} 72 + </div> 73 + </form> 74 + ); 75 + }
+1 -1
src/components/LoginPage.tsx
··· 1 - import { Login } from "@bigmoves/bff/components"; 2 1 import { profileLink } from "../utils.ts"; 2 + import { Login } from "./Login.tsx"; 3 3 4 4 export function LoginPage({ error }: Readonly<{ error?: string }>) { 5 5 return (
+10 -5
src/components/PhotoAltDialog.tsx
··· 1 1 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 2 import { AtUri } from "@atproto/syntax"; 3 - import { Button, Dialog, Textarea } from "@bigmoves/bff/components"; 3 + import { Button } from "./Button.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 5 + import { Label } from "./Label.tsx"; 6 + import { Textarea } from "./Textarea.tsx"; 4 7 5 8 export function PhotoAltDialog({ 6 9 photo, ··· 8 11 photo: PhotoView; 9 12 }>) { 10 13 return ( 11 - <Dialog id="photo-alt-dialog" class="z-100"> 12 - <Dialog.Content class="dark:bg-zinc-950 relative"> 14 + <Dialog id="photo-alt-dialog"> 15 + <Dialog.Content> 13 16 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 14 17 <Dialog.Title>Add alt text</Dialog.Title> 15 18 <div class="aspect-square relative"> ··· 24 27 _="on htmx:afterOnLoad trigger closeDialog" 25 28 > 26 29 <div class="my-2"> 27 - <label htmlFor="alt">Descriptive alt text</label> 30 + <Label htmlFor="alt">Descriptive alt text</Label> 28 31 <Textarea 29 32 id="alt" 30 33 name="alt" ··· 39 42 <Button type="submit" variant="primary" class="w-full"> 40 43 Save 41 44 </Button> 42 - <Dialog.Close class="w-full">Cancel</Dialog.Close> 45 + <Dialog.Close variant="secondary" class="w-full"> 46 + Close 47 + </Dialog.Close> 43 48 </div> 44 49 </form> 45 50 </Dialog.Content>
+3 -2
src/components/PhotoDialog.tsx
··· 1 1 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 2 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 3 import { AtUri } from "@atproto/syntax"; 4 - import { cn, Dialog } from "@bigmoves/bff/components"; 4 + import { cn } from "@bigmoves/bff/components"; 5 5 import { photoDialogLink } from "../utils.ts"; 6 + import { Dialog } from "./Dialog.tsx"; 6 7 7 8 export function PhotoDialog({ 8 9 gallery, ··· 16 17 prevImage?: PhotoView; 17 18 }>) { 18 19 return ( 19 - <Dialog id="photo-dialog" class="bg-zinc-950 z-100"> 20 + <Dialog id="photo-dialog" class="bg-zinc-950"> 20 21 <Dialog.X /> 21 22 {nextImage 22 23 ? (
+1 -1
src/components/PhotoExifButton.tsx
··· 3 3 export function PhotoExifButton({ photoUri }: Readonly<{ photoUri: string }>) { 4 4 return ( 5 5 <div 6 - class="bg-zinc-950 dark:bg-zinc-950 py-[1px] px-[3px] absolute bottom-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" 6 + class="bg-zinc-950/50 dark:bg-zinc-950/50 py-[1px] px-[3px] absolute bottom-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" 7 7 hx-get={`/dialogs/photo/${new AtUri(photoUri).rkey}/exif`} 8 8 hx-trigger="click" 9 9 hx-target="#layout"
+6 -3
src/components/PhotoExifDialog.tsx
··· 1 1 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 - import { Dialog } from "@bigmoves/bff/components"; 3 2 import { getOrderedExifData } from "../lib/photo.ts"; 3 + import { Dialog } from "./Dialog.tsx"; 4 4 5 5 export function PhotoExifDialog({ 6 6 photo, ··· 8 8 photo: PhotoView; 9 9 }>) { 10 10 return ( 11 - <Dialog id="photo-alt-dialog" class="z-100"> 12 - <Dialog.Content class="dark:bg-zinc-950 relative"> 11 + <Dialog id="photo-alt-dialog"> 12 + <Dialog.Content> 13 13 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 14 14 <Dialog.Title>Camera Settings</Dialog.Title> 15 15 <div class="aspect-square relative"> ··· 31 31 ))} 32 32 </div> 33 33 )} 34 + <Dialog.Close variant="secondary" class="w-full mt-4"> 35 + Close 36 + </Dialog.Close> 34 37 </Dialog.Content> 35 38 </Dialog> 36 39 );
+22 -7
src/components/PhotoPreview.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 1 2 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 3 import { Un$Typed } from "$lexicon/util.ts"; 3 4 import { AtUri } from "@atproto/syntax"; 4 5 import { AltTextButton } from "./AltTextButton.tsx"; 5 6 import { PhotoExifButton } from "./PhotoExifButton.tsx"; 7 + import { RemovePhotoDialogButton } from "./RemovePhotoDialog.tsx"; 6 8 7 9 export function PhotoPreview({ 8 10 photo, 11 + selectedGallery, 9 12 }: Readonly<{ 10 13 photo: Un$Typed<PhotoView>; 14 + selectedGallery?: GalleryView; 11 15 }>) { 12 - const rkey = new AtUri(photo.uri).rkey; 16 + const atUri = new AtUri(photo.uri); 17 + const did = atUri.hostname; 18 + const rkey = atUri.rkey; 13 19 return ( 14 20 <div 15 21 class="relative aspect-square bg-zinc-200 dark:bg-zinc-900" 16 - id={rkey} 22 + id={`photo-${rkey}`} 17 23 > 18 24 {photo.uri ? <AltTextButton photoUri={photo.uri} /> : null} 19 25 {photo.exif ? <PhotoExifButton photoUri={photo.uri} /> : null} 20 26 {photo.uri 21 27 ? ( 28 + <RemovePhotoDialogButton 29 + selectedGallery={selectedGallery} 30 + photoUri={photo.uri} 31 + /> 32 + ) 33 + : null} 34 + {photo.uri 35 + ? ( 22 36 <button 23 37 type="button" 24 - id={`delete-photo-${rkey}`} 25 - hx-delete={`/actions/photo/${rkey}`} 26 - class="bg-zinc-950 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center" 27 - _="on htmx:afterOnLoad remove me.parentNode" 38 + hx-get={`/dialogs/gallery/${did}/select?photoUri=${photo.uri}`} 39 + hx-trigger="click" 40 + hx-target="#layout" 41 + hx-swap="afterbegin" 42 + class="bg-zinc-950/50 z-10 absolute bottom-2 right-2 cursor-pointer size-4 flex items-center justify-center" 28 43 > 29 - <i class="fas fa-close text-white"></i> 44 + <i class="fas fa-plus text-white"></i> 30 45 </button> 31 46 ) 32 47 : null}
+13 -20
src/components/PhotoSelectButton.tsx
··· 3 3 4 4 export function PhotoSelectButton({ 5 5 galleryUri, 6 - itemUris, 7 6 photo, 8 7 }: Readonly<{ 9 8 galleryUri: string; 10 - itemUris: string[]; 11 9 photo: PhotoView; 12 10 }>) { 11 + const galleryRkey = new AtUri(galleryUri).rkey; 12 + const photoRkey = new AtUri(photo.uri).rkey; 13 13 return ( 14 14 <button 15 - hx-put={`/actions/gallery/${new AtUri(galleryUri).rkey}/${ 16 - itemUris.includes(photo.uri) ? "remove-photo" : "add-photo" 17 - }/${new AtUri(photo.uri).rkey}`} 18 - hx-swap="outerHTML" 15 + hx-put={`/actions/gallery/${galleryRkey}/remove-photo/${photoRkey}?selectedGallery=${ 16 + galleryUri ?? "" 17 + }`} 18 + hx-swap="none" 19 + hx-confirm="Are you sure you want to remove this photo from the gallery?" 19 20 type="button" 20 - data-added={itemUris.includes(photo.uri) ? "true" : "false"} 21 - class="group cursor-pointer relative aspect-square data-[added=true]:ring-2 ring-sky-500 disabled:opacity-50" 22 - _={`on htmx:beforeRequest add @disabled to me 23 - then on htmx:afterOnLoad 24 - remove @disabled from me 25 - if @data-added == 'true' 26 - set @data-added to 'false' 27 - remove #photo-${new AtUri(photo.uri).rkey} 28 - else 29 - set @data-added to 'true' 30 - end`} 21 + class="group cursor-pointer aspect-square relative" 22 + _={`on htmx:afterOnLoad remove me`} 31 23 > 32 - <div class="hidden group-data-[added=true]:block absolute top-2 right-2 z-30"> 33 - <i class="fa-check fa-solid text-sky-500 z-10" /> 24 + <div class="absolute top-2 right-2 z-30 size-4 bg-zinc-950/50 flex items-center justify-center"> 25 + <i class="fa-close fa-solid text-white z-10" /> 34 26 </div> 35 27 <img 36 28 src={photo.fullsize} 37 29 alt={photo.alt} 38 - class="absolute inset-0 w-full h-full object-contain" 30 + class="w-full h-full object-cover" 31 + loading="lazy" 39 32 /> 40 33 </button> 41 34 );
-61
src/components/PhotoSelectDialog.tsx
··· 1 - import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 - import { AtUri } from "@atproto/syntax"; 3 - import { Dialog } from "@bigmoves/bff/components"; 4 - import { PhotoSelectButton } from "./PhotoSelectButton.tsx"; 5 - 6 - export function PhotoSelectDialog({ 7 - galleryUri, 8 - itemUris, 9 - photos, 10 - }: Readonly<{ 11 - galleryUri: string; 12 - itemUris: string[]; 13 - photos: PhotoView[]; 14 - }>) { 15 - return ( 16 - <Dialog id="photo-select-dialog" class="z-100"> 17 - <Dialog.Content class="w-full max-w-5xl dark:bg-zinc-950 sm:min-h-screen flex flex-col relative"> 18 - <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 19 - <Dialog.Title>Add photos</Dialog.Title> 20 - {photos.length 21 - ? ( 22 - <p class="my-2 text-center"> 23 - Choose photos to add/remove from your gallery. Click close when 24 - done. 25 - </p> 26 - ) 27 - : null} 28 - {photos.length 29 - ? ( 30 - <div class="grid grid-cols-3 sm:grid-cols-5 gap-4 my-4 flex-1"> 31 - {photos.map((photo) => ( 32 - <PhotoSelectButton 33 - key={photo.cid} 34 - galleryUri={galleryUri} 35 - itemUris={itemUris} 36 - photo={photo} 37 - /> 38 - ))} 39 - </div> 40 - ) 41 - : ( 42 - <div class="flex-1 flex justify-center items-center my-30"> 43 - <p> 44 - No photos yet.{" "} 45 - <a 46 - href={`/upload?returnTo=${new AtUri(galleryUri).rkey}`} 47 - class="hover:underline font-semibold text-sky-500" 48 - > 49 - Upload 50 - </a>{" "} 51 - photos and return to add. 52 - </p> 53 - </div> 54 - )} 55 - <div class="w-full flex flex-col gap-2 mt-2"> 56 - <Dialog.Close class="w-full">Close</Dialog.Close> 57 - </div> 58 - </Dialog.Content> 59 - </Dialog> 60 - ); 61 - }
+29 -22
src/components/ProfileDialog.tsx
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 - import { Button, Dialog, Input, Textarea } from "@bigmoves/bff/components"; 3 2 import { AvatarInput } from "./AvatarInput.tsx"; 3 + import { Button } from "./Button.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 5 + import { Input } from "./Input.tsx"; 6 + import { Label } from "./Label.tsx"; 7 + import { Textarea } from "./Textarea.tsx"; 4 8 5 9 export function ProfileDialog({ 6 10 profile, ··· 8 12 profile: ProfileView; 9 13 }>) { 10 14 return ( 11 - <Dialog class="z-100"> 12 - <Dialog.Content class="dark:bg-zinc-950 relative"> 15 + <Dialog> 16 + <Dialog.Content> 13 17 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 14 18 <Dialog.Title>Edit my profile</Dialog.Title> 15 19 <form 16 20 id="profile-form" 17 21 hx-encoding="multipart/form-data" 22 + autocomplete="off" 18 23 _="on submit 19 24 halt the event 20 25 put 'Updating...' into #submit-button.innerText ··· 31 36 > 32 37 <AvatarInput profile={profile} /> 33 38 <div class="mb-4 relative"> 34 - <label htmlFor="displayName">Display Name</label> 39 + <Label htmlFor="displayName">Display Name</Label> 35 40 <Input 36 41 type="text" 37 42 id="displayName" 38 43 name="displayName" 39 44 placeholder="e.g. Ansel Lastname" 40 - class="dark:bg-zinc-800 dark:text-white" 41 45 value={profile.displayName} 42 46 autoFocus 47 + autocomplete="off" 43 48 /> 44 49 </div> 45 50 <div class="mb-4 relative"> 46 - <label htmlFor="description">Description</label> 51 + <Label htmlFor="description">Description</Label> 47 52 <Textarea 48 53 id="description" 49 54 name="description" ··· 54 59 {profile.description} 55 60 </Textarea> 56 61 </div> 57 - <Button 58 - type="submit" 59 - id="submit-button" 60 - variant="primary" 61 - class="w-full" 62 - > 63 - Update 64 - </Button> 65 - <Button 66 - variant="secondary" 67 - type="button" 68 - class="w-full" 69 - _={Dialog._closeOnClick} 70 - > 71 - Cancel 72 - </Button> 62 + <div class="flex flex-col gap-2"> 63 + <Button 64 + type="submit" 65 + id="submit-button" 66 + variant="primary" 67 + class="w-full" 68 + > 69 + Update 70 + </Button> 71 + <Button 72 + variant="secondary" 73 + type="button" 74 + class="w-full" 75 + _={Dialog._closeOnClick} 76 + > 77 + Cancel 78 + </Button> 79 + </div> 73 80 </form> 74 81 </Dialog.Content> 75 82 </Dialog>
+24 -33
src/components/ProfilePage.tsx
··· 6 6 import { Un$Typed } from "$lexicon/util.ts"; 7 7 import { AtUri } from "@atproto/syntax"; 8 8 import { LabelerPolicies } from "@bigmoves/bff"; 9 - import { Button, cn } from "@bigmoves/bff/components"; 10 9 import { getGalleryCameras } from "../lib/gallery.ts"; 11 10 import { 12 11 atprotoLabelValueDefinitions, ··· 22 21 } from "../utils.ts"; 23 22 import { ActorAvatar } from "./ActorAvatar.tsx"; 24 23 import { AvatarButton } from "./AvatarButton.tsx"; 24 + import { Button } from "./Button.tsx"; 25 25 import { CameraBadges } from "./CameraBadges.tsx"; 26 26 import { FollowButton } from "./FollowButton.tsx"; 27 27 import { LabelDefinitionButton } from "./LabelDefinitionButton.tsx"; ··· 125 125 ? ( 126 126 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end"> 127 127 <Button 128 - variant="primary" 128 + variant="secondary" 129 129 class="w-full sm:w-fit whitespace-nowrap" 130 130 asChild 131 131 > 132 132 <a href="/upload"> 133 133 <i class="fa-solid fa-upload mr-2" /> 134 - Upload 134 + Photo library 135 135 </a> 136 136 </Button> 137 137 <Button 138 - variant="primary" 138 + variant="secondary" 139 139 type="button" 140 140 hx-get="/dialogs/profile" 141 141 hx-target="#layout" 142 142 hx-swap="afterbegin" 143 143 class="w-full sm:w-fit whitespace-nowrap" 144 144 > 145 - Edit Profile 145 + Edit profile 146 146 </Button> 147 147 <Button 148 148 variant="primary" ··· 152 152 hx-target="#layout" 153 153 hx-swap="afterbegin" 154 154 > 155 - Create Gallery 155 + Create gallery 156 156 </Button> 157 157 </div> 158 158 ) ··· 165 165 > 166 166 {isLabeler 167 167 ? ( 168 - <button 169 - type="button" 168 + <Button 169 + variant="tab" 170 170 name="tab" 171 + class="flex-1" 171 172 value="favs" 172 173 hx-get={profileLink(profile.handle)} 173 174 hx-target="#profile-page" 174 175 hx-swap="outerHTML" 175 - class={cn( 176 - "flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold", 177 - selectedTab === "labels" && "bg-zinc-100 dark:bg-zinc-800", 178 - )} 179 176 role="tab" 180 177 aria-selected={selectedTab === "labels"} 181 178 aria-controls="tab-content" 182 179 > 183 180 Labels 184 - </button> 181 + </Button> 185 182 ) 186 183 : ( 187 - <button 188 - type="button" 184 + <Button 185 + variant="tab" 189 186 name="tab" 187 + class="flex-1" 190 188 value="galleries" 191 189 hx-get={profileLink(profile.handle)} 192 190 hx-target="#profile-page" 193 191 hx-swap="outerHTML" 194 - class={cn( 195 - "flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold", 196 - selectedTab === "galleries" && "bg-zinc-100 dark:bg-zinc-800", 197 - )} 198 192 role="tab" 199 193 aria-selected={selectedTab === "galleries"} 200 194 aria-controls="tab-content" 201 195 > 202 196 Galleries 203 - </button> 197 + </Button> 204 198 )} 205 199 206 200 {isCreator && ( 207 - <button 208 - type="button" 201 + <Button 202 + variant="tab" 209 203 name="tab" 204 + class="flex-1" 210 205 value="favs" 211 206 hx-get={profileLink(profile.handle)} 212 207 hx-target="#profile-page" 213 208 hx-swap="outerHTML" 214 - class={cn( 215 - "flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold", 216 - selectedTab === "favs" && "bg-zinc-100 dark:bg-zinc-800", 217 - )} 218 209 role="tab" 219 210 aria-selected={selectedTab === "favs"} 220 211 aria-controls="tab-content" 221 212 > 222 213 Favs 223 - </button> 214 + </Button> 224 215 )} 225 216 </div> 226 217 {selectedTab === "labels" && labelerDefinitions ··· 228 219 : null} 229 220 {selectedTab === "galleries" 230 221 ? ( 231 - <div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4"> 222 + <div class="grid grid-cols-3 gap-1 mb-4"> 232 223 {galleries?.length 233 224 ? ( 234 225 galleries.map((gallery) => ( ··· 245 236 : null} 246 237 {selectedTab === "favs" 247 238 ? ( 248 - <div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4"> 239 + <div class="grid grid-cols-3 gap-1 mb-4"> 249 240 {galleryFavs?.length 250 241 ? ( 251 242 galleryFavs.map((gallery) => ( ··· 321 312 gallery.creator.handle, 322 313 new AtUri(gallery.uri).rkey, 323 314 )} 324 - class="cursor-pointer relative aspect-square" 315 + class="cursor-pointer relative aspect-3/4" 325 316 > 326 317 {modDecision && !modDecision.isMe 327 318 ? ( ··· 345 336 /> 346 337 ) 347 338 : <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" />} 348 - <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 flex items-center gap-2"> 339 + <div class="absolute sm:flex hidden bottom-0 left-0 bg-black/80 text-white p-2 items-center gap-2"> 349 340 {(gallery.record as Gallery).title} 350 341 </div> 351 342 </a> ··· 365 356 gallery.creator.handle, 366 357 new AtUri(gallery.uri).rkey, 367 358 )} 368 - class="cursor-pointer relative aspect-square" 359 + class="cursor-pointer relative aspect-3/4" 369 360 > 370 361 {gallery.items?.length 371 362 ? ( ··· 376 367 /> 377 368 ) 378 369 : <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" />} 379 - <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 flex items-center gap-2"> 370 + <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 hidden sm:flex items-center gap-2"> 380 371 <ActorAvatar profile={gallery.creator} size={20} />{" "} 381 372 {(gallery.record as Gallery).title} 382 373 </div>
+93
src/components/RemovePhotoDialog.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { Button } from "./Button.tsx"; 4 + import { Dialog } from "./Dialog.tsx"; 5 + 6 + export function RemovePhotoDialog( 7 + { photoUri, galleries, selectedGallery }: Readonly< 8 + { 9 + photoUri: string; 10 + galleries: GalleryView[]; 11 + selectedGallery?: GalleryView; 12 + } 13 + >, 14 + ) { 15 + const rkey = new AtUri(photoUri).rkey; 16 + return ( 17 + <Dialog> 18 + <Dialog.Content class="gap-4"> 19 + <Dialog.Title>Remove photo</Dialog.Title> 20 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 21 + 22 + {galleries.length > 0 23 + ? ( 24 + <div id="photo-galleries" class="flex flex-col gap-2"> 25 + <h2> 26 + This photo appears in the following galleries. Select to remove. 27 + </h2> 28 + <ul class="divide-y divide-zinc-200 dark:divide-zinc-800 border-t border-b border-zinc-200 dark:border-zinc-800"> 29 + {galleries.map((gallery) => ( 30 + <li 31 + key={gallery.uri} 32 + class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800" 33 + > 34 + <button 35 + type="button" 36 + hx-put={`/actions/gallery/${ 37 + new AtUri(gallery.uri).rkey 38 + }/remove-photo/${rkey}?selectedGallery=${ 39 + selectedGallery?.uri ?? "" 40 + }`} 41 + class="flex justify-between items-center text-left w-full px-2 py-4" 42 + _="on htmx:afterRequest 43 + if me.closest('ul').children.length is 1 44 + then remove #photo-galleries 45 + else remove me.closest('li')" 46 + > 47 + {gallery.record.title || "Untitled Gallery"} 48 + <i class="fa fa-close" /> 49 + </button> 50 + </li> 51 + ))} 52 + </ul> 53 + </div> 54 + ) 55 + : null} 56 + 57 + <Button 58 + variant="destructive" 59 + hx-delete={`/actions/photo/${rkey}?selectedGallery=${ 60 + selectedGallery?.uri ?? "" 61 + }`} 62 + hx-swap="none" 63 + hx-confirm="Are you sure you want to delete this photo? This action cannot be undone." 64 + > 65 + Delete photo 66 + </Button> 67 + </Dialog.Content> 68 + </Dialog> 69 + ); 70 + } 71 + 72 + export function RemovePhotoDialogButton( 73 + { selectedGallery, photoUri }: Readonly< 74 + { selectedGallery?: GalleryView; photoUri: string } 75 + >, 76 + ) { 77 + const rkey = new AtUri(photoUri).rkey; 78 + return ( 79 + <button 80 + type="button" 81 + class="bg-zinc-950/50 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center" 82 + hx-get={`/dialogs/photo/${rkey}/remove?selectedGallery=${ 83 + selectedGallery?.uri ?? "" 84 + }`} 85 + hx-trigger="click" 86 + hx-target="#layout" 87 + hx-swap="afterbegin" 88 + _="on click halt" 89 + > 90 + <i class="fas fa-close text-white"></i> 91 + </button> 92 + ); 93 + }
+1 -1
src/components/ShareGalleryButton.tsx
··· 1 1 import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 - import { Button } from "@bigmoves/bff/components"; 3 2 import { publicGalleryLink } from "../utils.ts"; 3 + import { Button } from "./Button.tsx"; 4 4 5 5 export function ShareGalleryButton( 6 6 { gallery }: Readonly<{ gallery: GalleryView }>,
+13
src/components/Textarea.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + import type { JSX } from "preact"; 3 + 4 + export type TextareaProps = JSX.TextareaHTMLAttributes<HTMLTextAreaElement>; 5 + 6 + export function Textarea(props: TextareaProps): JSX.Element { 7 + const { class: classProp, ...rest } = props; 8 + const className = cn( 9 + "grain-input", 10 + classProp, 11 + ); 12 + return <textarea class={className} {...rest} />; 13 + }
+9 -17
src/components/Timeline.tsx
··· 1 - import { cn } from "@bigmoves/bff/components"; 2 1 import { type TimelineItem } from "../lib/timeline.ts"; 2 + import { Button } from "./Button.tsx"; 3 3 import { Header } from "./Header.tsx"; 4 4 import { TimelineItem as Item } from "./TimelineItem.tsx"; 5 5 ··· 21 21 <> 22 22 <div class="my-4 pb-4 border-b border-zinc-200 dark:border-zinc-800"> 23 23 <div class="flex sm:w-fit"> 24 - <button 25 - type="button" 24 + <Button 25 + variant="tab" 26 + class="flex-1" 26 27 hx-get={`/?graph=${selectedGraph}`} 27 28 hx-target="#timeline-page" 28 29 hx-swap="outerHTML" 29 - class={cn( 30 - "flex-1 py-2 sm:min-w-[120px] px-4 cursor-pointer font-semibold", 31 - !selectedTab && 32 - "bg-zinc-100 dark:bg-zinc-800 font-semibold", 33 - )} 34 30 role="tab" 35 31 aria-selected={!selectedTab} 36 32 aria-controls="tab-content" 37 33 > 38 34 Timeline 39 - </button> 40 - <button 41 - type="button" 35 + </Button> 36 + <Button 37 + variant="tab" 38 + class="flex-1" 42 39 hx-get={`/?tab=following&graph=${selectedGraph}`} 43 40 hx-target="#timeline-page" 44 41 hx-swap="outerHTML" 45 - class={cn( 46 - "flex-1 py-2 sm:min-w-[120px] px-4 cursor-pointer font-semibold", 47 - selectedTab === "following" && 48 - "bg-zinc-100 dark:bg-zinc-800 font-semibold", 49 - )} 50 42 role="tab" 51 43 aria-selected={selectedTab === "following"} 52 44 aria-controls="tab-content" 53 45 _="on click js document.title = 'Following — Grain'; end" 54 46 > 55 47 Following 56 - </button> 48 + </Button> 57 49 </div> 58 50 </div> 59 51 <div id="tab-content" role="tabpanel">
+88 -19
src/components/UploadPage.tsx
··· 1 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 2 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 1 3 import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 - import { Button } from "@bigmoves/bff/components"; 4 + import { Un$Typed } from "$lexicon/util.ts"; 3 5 import { profileLink } from "../utils.ts"; 4 6 import { Breadcrumb } from "./Breadcrumb.tsx"; 7 + import { Button } from "./Button.tsx"; 8 + import { GallerySelectDialogButton } from "./GallerySelectDialog.tsx"; 5 9 import { PhotoPreview } from "./PhotoPreview.tsx"; 6 10 7 11 export function UploadPage({ 8 - handle, 12 + userDid, 13 + userHandle, 9 14 photos, 10 15 returnTo, 11 - }: Readonly<{ handle: string; photos: PhotoView[]; returnTo?: string }>) { 16 + selectedGallery, 17 + }: Readonly< 18 + { 19 + userDid: string; 20 + userHandle: string; 21 + photos: PhotoView[]; 22 + returnTo?: string; 23 + selectedGallery?: Un$Typed<GalleryView>; 24 + } 25 + >) { 12 26 return ( 13 27 <div class="flex flex-col px-4 pt-4 mb-4 space-y-4"> 14 28 <Breadcrumb 15 29 items={[ 16 30 returnTo 17 31 ? { label: "Gallery", href: returnTo } 18 - : { label: "Profile", href: profileLink(handle) }, 32 + : { label: "Profile", href: profileLink(userHandle) }, 19 33 { label: "Upload" }, 20 34 ]} 21 35 /> 22 - <div> 23 - Upload 10 photos at a time. Click{" "} 24 - <button 25 - type="button" 26 - hx-get="/dialogs/gallery/new" 27 - hx-target="#layout" 28 - hx-swap="afterbegin" 29 - class="font-semibold hover:underline cursor-pointer text-sky-500" 30 - > 31 - here 32 - </button>{" "} 33 - to create a gallery or add to existing galleries once you're done! 34 - </div> 35 36 <form 36 37 hx-encoding="multipart/form-data" 37 38 _="on change from #file-input call Grain.uploadPage.uploadPhotos(me)" 38 39 > 40 + <input hidden name="galleryUri" value={selectedGallery?.uri} /> 39 41 <Button variant="primary" class="mb-4 w-full sm:w-fit" asChild> 40 42 <label> 41 - <i class="fa fa-plus"></i> Add photos 43 + <i class="fa fa-plus"></i>{" "} 44 + {selectedGallery ? "Add photos to gallery" : "Add photos"} 42 45 <input 43 46 id="file-input" 44 47 class="hidden" ··· 70 73 </button> 71 74 </label> 72 75 </form> 76 + <div class="flex flex-col sm:flex-row items-center justify-between gap-2"> 77 + {selectedGallery 78 + ? ( 79 + <div className="flex-1 flex items-center my-2"> 80 + Showing photos for &quot;{(selectedGallery?.record as Gallery) 81 + .title}&quot;&nbsp; ( 82 + <span id="photos-count">{photos.length}</span> 83 + ) 84 + </div> 85 + ) 86 + : ( 87 + <div className="flex-1 flex items-center my-2"> 88 + All photos&nbsp;( 89 + <span id="photos-count">{photos.length}</span> 90 + ) 91 + </div> 92 + )} 93 + <div class="flex items-center flex-col sm:flex-row gap-2 w-full justify-end flex-1"> 94 + {selectedGallery 95 + ? ( 96 + <Button variant="secondary" class="w-full sm:w-fit" asChild> 97 + <a 98 + href="/upload" 99 + title="Clear gallery selection" 100 + > 101 + <i class="fa fa-close mr-2" /> 102 + Remove gallery filter 103 + </a> 104 + </Button> 105 + ) 106 + : null} 107 + {!selectedGallery 108 + ? <GallerySelectDialogButton userDid={userDid} /> 109 + : null} 110 + { 111 + /* {!selectedGallery && ( 112 + <Button variant="secondary" class="w-full sm:w-fit"> 113 + <i class="fa fa-plus"></i> Create gallery 114 + </Button> 115 + )} */ 116 + } 117 + { 118 + /* {selectedGallery 119 + ? ( 120 + <Button variant="secondary" asChild> 121 + <a 122 + class="w-full sm:w-fit" 123 + href={galleryLink( 124 + selectedGallery.creator.handle, 125 + new AtUri(selectedGallery.uri).rkey, 126 + )} 127 + > 128 + Go to gallery page 129 + </a> 130 + </Button> 131 + ) 132 + : null} */ 133 + } 134 + </div> 135 + </div> 73 136 <div 74 137 id="image-preview" 75 138 class="w-full h-full grid grid-cols-2 sm:grid-cols-5 gap-2" 76 139 > 77 - {photos.map((photo) => <PhotoPreview key={photo.cid} photo={photo} />)} 140 + {photos.map((photo) => ( 141 + <PhotoPreview 142 + key={photo.cid} 143 + photo={photo} 144 + selectedGallery={selectedGallery} 145 + /> 146 + ))} 78 147 </div> 79 148 </div> 80 149 );
+156
src/input.css
··· 10 10 font-style: normal; 11 11 font-display: swap; 12 12 } 13 + 14 + :root { 15 + --focus-ring-light: var(--color-zinc-200); 16 + --focus-ring-dark: var(--color-zinc-500); 17 + --btn-border-radius: var(--radius-sm); 18 + --input-border-radius: var(--radius-sm); 19 + } 20 + 21 + @layer base { 22 + :focus-visible { 23 + outline: none; 24 + box-shadow: 0 0 0 2px var(--focus-ring-light); 25 + border-radius: var(--radius-sm); 26 + } 27 + } 28 + 29 + @media (prefers-color-scheme: dark) { 30 + :focus-visible { 31 + box-shadow: 0 0 0 2px var(--focus-ring-dark); 32 + } 33 + } 34 + 35 + @layer components { 36 + .grain-label { 37 + display: block; 38 + margin-bottom: --spacing(2); 39 + } 40 + 41 + .grain-input { 42 + border: 1px solid var(--color-zinc-100); 43 + padding-inline: --spacing(3); 44 + padding-block: --spacing(2); 45 + width: 100%; 46 + background-color: var(--color-zinc-100); 47 + color: var(--color-zinc-900); 48 + font-size: var(--font-size-base); 49 + line-height: var(--line-height-normal); 50 + border-radius: var(--input-border-radius); 51 + box-shadow: none; 52 + outline: none; 53 + } 54 + 55 + .grain-input::placeholder { 56 + color: var(--color-zinc-400); 57 + } 58 + 59 + .grain-input:disabled { 60 + background-color: var(--color-zinc-100); 61 + cursor: not-allowed; 62 + opacity: 0.7; 63 + } 64 + 65 + .grain-input.grain-input-error { 66 + border-color: var(--color-red-500); 67 + } 68 + 69 + @media (prefers-color-scheme: dark) { 70 + .grain-input { 71 + background-color: var(--color-zinc-800); 72 + color: var(--color-zinc-50); 73 + border-color: var(--color-zinc-800); 74 + } 75 + } 76 + 77 + .grain-btn { 78 + display: inline-block; 79 + padding-inline: --spacing(3); 80 + padding-block: --spacing(1.5); 81 + font-size: var(--font-size-base); 82 + font-weight: 500; 83 + line-height: var(--line-height-normal); 84 + text-align: center; 85 + cursor: pointer; 86 + border-radius: var(--btn-border-radius); 87 + box-shadow: none; 88 + outline: none; 89 + 90 + &.grain-btn-primary { 91 + background-color: var(--color-sky-500); 92 + color: var(--color-white); 93 + border: 1px solid var(--color-sky-500); 94 + } 95 + 96 + &.grain-btn-secondary { 97 + background-color: var(--color-zinc-100); 98 + color: var(--color-zinc-800); 99 + border: 1px solid var(--color-zinc-100); 100 + } 101 + 102 + @media (prefers-color-scheme: dark) { 103 + &.grain-btn-secondary { 104 + background-color: var(--color-zinc-800); 105 + color: var(--color-zinc-100); 106 + border: 1px solid var(--color-zinc-800); 107 + } 108 + } 109 + 110 + &.grain-btn-ghost { 111 + background-color: transparent; 112 + border: 1px solid transparent; 113 + } 114 + 115 + &.grain-btn-destructive { 116 + background-color: var(--color-red-500); 117 + color: var(--color-white); 118 + border: 1px solid var(--color-red-500); 119 + } 120 + 121 + &.grain-btn-tab { 122 + display: flex; 123 + align-items: center; 124 + justify-content: center; 125 + min-width: 120px; 126 + padding-inline: --spacing(3); 127 + padding-block: --spacing(2); 128 + cursor: pointer; 129 + font-weight: var(--font-weight-semibold); 130 + text-align: center; 131 + 132 + &[aria-selected="true"] { 133 + background-color: var(--color-zinc-100); 134 + font-weight: 600; 135 + } 136 + 137 + @media (prefers-color-scheme: dark) { 138 + &[aria-selected="true"] { 139 + background-color: var(--color-zinc-800); 140 + } 141 + } 142 + } 143 + } 144 + 145 + .grain-input:focus-visible, 146 + .grain-btn:focus-visible { 147 + box-shadow: 0 0 0 2px var(--focus-ring-light); 148 + } 149 + 150 + @media (prefers-color-scheme: dark) { 151 + .grain-input:focus-visible, 152 + .grain-btn:focus-visible { 153 + box-shadow: 0 0 0 2px var(--focus-ring-dark); 154 + } 155 + } 156 + 157 + body[data-scroll-locked] { 158 + overflow: hidden !important; 159 + overscroll-behavior: contain; 160 + position: relative !important; 161 + padding-left: 0px; 162 + padding-top: 0px; 163 + padding-right: 0px; 164 + margin-left: 0; 165 + margin-top: 0; 166 + margin-right: 0px !important; 167 + } 168 + }
+98
src/lib/gallery.ts
··· 48 48 "social.grain.photo", 49 49 { 50 50 where: [{ field: "uri", in: photoUris }], 51 + orderBy: [{ field: "createdAt", direction: "asc" }], 51 52 }, 52 53 ); 53 54 ··· 196 197 } 197 198 return Array.from(cameras); 198 199 } 200 + 201 + export function queryGalleriesByName( 202 + userDid: string, 203 + nameQuery: string, 204 + ctx: BffContext, 205 + ): GalleryView[] { 206 + if (!nameQuery || !userDid) return []; 207 + const { items: galleries } = ctx.indexService.getRecords< 208 + WithBffMeta<Gallery> 209 + >( 210 + "social.grain.gallery", 211 + { 212 + where: [ 213 + { field: "did", equals: userDid }, 214 + { field: "title", contains: nameQuery }, 215 + ], 216 + orderBy: [{ field: "createdAt", direction: "desc" }], 217 + }, 218 + ); 219 + if (!galleries.length) return []; 220 + 221 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 222 + 223 + const profile = getActorProfile(userDid, ctx); 224 + if (!profile) return []; 225 + 226 + const uris = galleries.map((g) => g.uri); 227 + const labels = ctx.indexService.queryLabels({ subjects: uris }); 228 + 229 + return galleries.map((gallery) => 230 + galleryToView( 231 + gallery, 232 + profile, 233 + galleryPhotosMap.get(gallery.uri) ?? [], 234 + labels, 235 + ) 236 + ); 237 + } 238 + 239 + export function getGalleryPhotos( 240 + galleryUri: string, 241 + ctx: BffContext, 242 + ): PhotoView[] { 243 + if (!galleryUri) return []; 244 + const { items: galleryItems } = ctx.indexService.getRecords< 245 + WithBffMeta<GalleryItem> 246 + >( 247 + "social.grain.gallery.item", 248 + { 249 + where: [{ field: "gallery", equals: galleryUri }], 250 + }, 251 + ); 252 + const photoUris = galleryItems.map((item) => item.item).filter(Boolean); 253 + if (!photoUris.length) return []; 254 + const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>( 255 + "social.grain.photo", 256 + { 257 + where: [{ field: "uri", in: photoUris }], 258 + orderBy: [{ field: "createdAt", direction: "desc" }], 259 + }, 260 + ); 261 + const { items: photosExif } = ctx.indexService.getRecords< 262 + WithBffMeta<PhotoExif> 263 + >( 264 + "social.grain.photo.exif", 265 + { 266 + where: [{ field: "photo", in: photoUris }], 267 + }, 268 + ); 269 + const photosMap = new Map<string, PhotoWithExif>(); 270 + const exifMap = new Map<string, WithBffMeta<PhotoExif>>(); 271 + for (const exif of photosExif) { 272 + exifMap.set(exif.photo, exif); 273 + } 274 + for (const photo of photos) { 275 + const exif = exifMap.get(photo.uri); 276 + photosMap.set(photo.uri, exif ? { ...photo, exif } : photo); 277 + } 278 + // Get the gallery DID from the URI 279 + const did = (() => { 280 + try { 281 + return new AtUri(galleryUri).hostname; 282 + } catch { 283 + return undefined; 284 + } 285 + })(); 286 + // Return PhotoView[] in the order of photo creation time (already sorted by SQL) 287 + return photos 288 + .map((photo) => { 289 + const exif = exifMap.get(photo.uri); 290 + if (did) { 291 + return photoToView(did, exif ? { ...photo, exif } : photo, exif); 292 + } 293 + return undefined; 294 + }) 295 + .filter(isPhotoView); 296 + }
+49
src/lib/photo.ts
··· 1 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 2 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 3 + import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 1 4 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 2 5 import { ExifView, PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 6 import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts"; ··· 5 8 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 6 9 import { format, parseISO } from "date-fns"; 7 10 import { PUBLIC_URL, USE_CDN } from "../env.ts"; 11 + import { getActorProfile } from "./actor.ts"; 12 + import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 8 13 9 14 export function getPhoto( 10 15 uri: string, ··· 194 199 return aIdx - bIdx; 195 200 }); 196 201 } 202 + 203 + export function getPhotoGalleries( 204 + photoUri: string, 205 + ctx: BffContext, 206 + ): GalleryView[] { 207 + const { items: galleryItems } = ctx.indexService.getRecords< 208 + WithBffMeta<GalleryItem> 209 + >( 210 + "social.grain.gallery.item", 211 + { 212 + where: [{ field: "item", equals: photoUri }], 213 + }, 214 + ); 215 + 216 + const galleryUris = Array.from( 217 + new Set(galleryItems.map((item) => item.gallery)), 218 + ); 219 + if (galleryUris.length === 0) return []; 220 + 221 + const { items: galleries } = ctx.indexService.getRecords< 222 + WithBffMeta<Gallery> 223 + >( 224 + "social.grain.gallery", 225 + { 226 + where: [{ field: "uri", in: galleryUris }], 227 + }, 228 + ); 229 + if (!galleries.length) return []; 230 + 231 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 232 + const labels = ctx.indexService.queryLabels({ subjects: galleryUris }); 233 + return galleries 234 + .map((gallery) => { 235 + const profile = getActorProfile(gallery.did, ctx); 236 + if (!profile) return undefined; 237 + return galleryToView( 238 + gallery, 239 + profile, 240 + galleryPhotosMap.get(gallery.uri) ?? [], 241 + labels ?? [], 242 + ); 243 + }) 244 + .filter((g): g is GalleryView => Boolean(g)); 245 + }
+40 -51
src/main.tsx
··· 4 4 import { LoginPage } from "./components/LoginPage.tsx"; 5 5 import { PDS_HOST_URL } from "./env.ts"; 6 6 import { onError } from "./lib/errors.ts"; 7 - import * as actionHandlers from "./routes/actions.tsx"; 7 + import * as actions from "./routes/actions.tsx"; 8 8 import { handler as communityGuidelinesHandler } from "./routes/community_guidelines.tsx"; 9 - import * as dialogHandlers from "./routes/dialogs.tsx"; 9 + import * as dialogs from "./routes/dialogs.tsx"; 10 10 import { handler as exploreHandler } from "./routes/explore.tsx"; 11 11 import { handler as followersHandler } from "./routes/followers.tsx"; 12 12 import { handler as followsHandler } from "./routes/follows.tsx"; 13 13 import { handler as galleryHandler } from "./routes/gallery.tsx"; 14 - import * as legalHandlers from "./routes/legal.tsx"; 14 + import * as legal from "./routes/legal.tsx"; 15 15 import { handler as notificationsHandler } from "./routes/notifications.tsx"; 16 16 import { handler as onboardHandler } from "./routes/onboard.tsx"; 17 17 import { handler as profileHandler } from "./routes/profile.tsx"; ··· 61 61 route("/upload", uploadHandler), 62 62 route("/onboard", onboardHandler), 63 63 route("/support", supportHandler), 64 - route("/support/privacy", legalHandlers.privacyHandler), 65 - route("/support/terms", legalHandlers.termsHandler), 66 - route("/support/copyright", legalHandlers.copyrightHandler), 64 + route("/support/privacy", legal.privacyHandler), 65 + route("/support/terms", legal.termsHandler), 66 + route("/support/copyright", legal.copyrightHandler), 67 67 route("/support/community-guidelines", communityGuidelinesHandler), 68 - route("/dialogs/create-account", dialogHandlers.createAccount), 69 - route("/dialogs/gallery/new", dialogHandlers.createGallery), 70 - route("/dialogs/gallery/:rkey", dialogHandlers.editGallery), 71 - route("/dialogs/gallery/:rkey/sort", dialogHandlers.sortGallery), 72 - route("/dialogs/label/:src/:val", dialogHandlers.labelValueDefinition), 73 - route("/dialogs/profile", dialogHandlers.editProfile), 74 - route("/dialogs/avatar/:handle", dialogHandlers.avatar), 75 - route("/dialogs/image", dialogHandlers.image), 76 - route("/dialogs/photo/:rkey/alt", dialogHandlers.photoAlt), 68 + route("/dialogs/create-account", dialogs.createAccount), 69 + route("/dialogs/gallery/new", dialogs.createGallery), 70 + route("/dialogs/gallery/:rkey", dialogs.editGallery), 71 + route("/dialogs/gallery/:rkey/photos", dialogs.galleryPhotoSelect), 72 + route("/dialogs/gallery/:rkey/edit", dialogs.editGalleryDetails), 73 + route("/dialogs/gallery/:rkey/sort", dialogs.sortGallery), 74 + route("/dialogs/gallery/:rkey/library", dialogs.galleryAddFromLibrary), 75 + route("/dialogs/gallery/:did/select", dialogs.gallerySelect), 76 + route("/dialogs/label/:src/:val", dialogs.labelValueDefinition), 77 + route("/dialogs/profile", dialogs.editProfile), 78 + route("/dialogs/avatar/:handle", dialogs.avatar), 79 + route("/dialogs/image", dialogs.image), 80 + route("/dialogs/photo/:rkey/remove", dialogs.photoRemove), 81 + route("/dialogs/photo/:rkey/alt", dialogs.photoAlt), 82 + route("/dialogs/photo/:rkey/exif", dialogs.photoExif), 83 + route("/dialogs/photo/:did/:rkey/exif-overlay", dialogs.photoExifOverlay), 84 + route("/dialogs/exif-info", dialogs.exifInfo), 85 + route("/actions/update-seen", ["POST"], actions.updateSeen), 86 + route("/actions/follow/:followeeDid", ["POST"], actions.follow), 87 + route("/actions/follow/:followeeDid/:rkey", ["DELETE"], actions.unfollow), 88 + route("/actions/create-edit", ["POST"], actions.galleryCreateEdit), 89 + route("/actions/gallery/delete", ["POST"], actions.galleryDelete), 77 90 route( 78 - "/dialogs/photo/:rkey/exif", 79 - dialogHandlers.photoExif, 91 + "/actions/gallery/:rkey/add-photos", 92 + ["PUT"], 93 + actions.galleryAddPhotos, 80 94 ), 81 - route( 82 - "/dialogs/photo/:did/:rkey/exif-overlay", 83 - dialogHandlers.photoExifOverlay, 84 - ), 85 - route( 86 - "/dialogs/exif-info", 87 - dialogHandlers.exifInfo, 88 - ), 89 - route( 90 - "/dialogs/photo-select/:galleryRkey", 91 - dialogHandlers.galleryPhotoSelect, 92 - ), 93 - route("/actions/update-seen", ["POST"], actionHandlers.updateSeen), 94 - route("/actions/follow/:followeeDid", ["POST"], actionHandlers.follow), 95 - route( 96 - "/actions/follow/:followeeDid/:rkey", 97 - ["DELETE"], 98 - actionHandlers.unfollow, 99 - ), 100 - route("/actions/create-edit", ["POST"], actionHandlers.galleryCreateEdit), 101 - route("/actions/gallery/delete", ["POST"], actionHandlers.galleryDelete), 102 95 route( 103 96 "/actions/gallery/:galleryRkey/add-photo/:photoRkey", 104 97 ["PUT"], 105 - actionHandlers.galleryAddPhoto, 98 + actions.galleryAddPhoto, 106 99 ), 107 100 route( 108 101 "/actions/gallery/:galleryRkey/remove-photo/:photoRkey", 109 102 ["PUT"], 110 - actionHandlers.galleryRemovePhoto, 111 - ), 112 - route("/actions/photo/:rkey", ["PUT"], actionHandlers.photoEdit), 113 - route("/actions/photo/:rkey", ["DELETE"], actionHandlers.photoDelete), 114 - route("/actions/photo", ["POST"], actionHandlers.uploadPhoto), 115 - route("/actions/favorite", ["POST"], actionHandlers.galleryFavorite), 116 - route("/actions/profile", ["PUT"], actionHandlers.profileUpdate), 117 - route( 118 - "/actions/gallery/:rkey/sort", 119 - ["POST"], 120 - actionHandlers.gallerySort, 103 + actions.galleryRemovePhoto, 121 104 ), 122 - route("/actions/get-blob", ["GET"], actionHandlers.getBlob), 105 + route("/actions/photo/:rkey", ["PUT"], actions.photoEdit), 106 + route("/actions/photo/:rkey", ["DELETE"], actions.photoDelete), 107 + route("/actions/photo", ["POST"], actions.uploadPhoto), 108 + route("/actions/favorite", ["POST"], actions.galleryFavorite), 109 + route("/actions/profile", ["PUT"], actions.profileUpdate), 110 + route("/actions/gallery/:rkey/sort", ["POST"], actions.gallerySort), 111 + route("/actions/get-blob", ["GET"], actions.getBlob), 123 112 route("/:did/:collection/:rkey", recordHandler), 124 113 ], 125 114 });
+152 -23
src/routes/actions.tsx
··· 2 2 import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 3 import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 4 4 import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 5 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 5 6 import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 6 7 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 7 8 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; ··· 10 11 import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 11 12 import { FavoriteButton } from "../components/FavoriteButton.tsx"; 12 13 import { FollowButton } from "../components/FollowButton.tsx"; 14 + import { GalleryEditPhotosDialog } from "../components/GalleryEditPhotosDialog.tsx"; 13 15 import { GalleryInfo } from "../components/GalleryInfo.tsx"; 14 16 import { GalleryLayout } from "../components/GalleryLayout.tsx"; 15 17 import { PhotoPreview } from "../components/PhotoPreview.tsx"; 16 18 import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx"; 19 + import { getActorPhotos } from "../lib/actor.ts"; 17 20 import { getFollowers } from "../lib/follow.ts"; 18 21 import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts"; 19 22 import { getPhoto, photoToView } from "../lib/photo.ts"; 20 23 import type { State } from "../state.ts"; 21 - import { galleryLink } from "../utils.ts"; 24 + import { galleryLink, profileLink, uploadPageLink } from "../utils.ts"; 22 25 23 26 export const updateSeen: RouteHandler = ( 24 27 _req, ··· 127 130 _params, 128 131 ctx: BffContext<State>, 129 132 ) => { 130 - ctx.requireAuth(); 133 + const { handle } = ctx.requireAuth(); 131 134 const formData = await req.formData(); 132 135 const uri = formData.get("uri") as string; 133 136 await deleteGallery(uri, ctx); 134 - return ctx.redirect("/"); 137 + return ctx.redirect(profileLink(handle)); 138 + }; 139 + 140 + export const galleryAddPhotos: RouteHandler = async ( 141 + req, 142 + params, 143 + ctx: BffContext<State>, 144 + ) => { 145 + const { did } = ctx.requireAuth(); 146 + const galleryRkey = params.rkey; 147 + const formData = await req.formData(); 148 + const uris = formData.getAll("photoUri") as string[]; 149 + const gallery = getGallery(did, galleryRkey, ctx); 150 + if (!gallery) return ctx.next(); 151 + 152 + const creates = []; 153 + let position = gallery.items?.length ?? 0; 154 + for (const uri of uris) { 155 + creates.push({ 156 + collection: "social.grain.gallery.item", 157 + data: { 158 + gallery: gallery.uri, 159 + item: uri, 160 + createdAt: new Date().toISOString(), 161 + position, 162 + }, 163 + }); 164 + position++; 165 + } 166 + await ctx.createRecords<WithBffMeta<GalleryItem>>(creates); 167 + 168 + const updatedGallery = getGallery(did, galleryRkey, ctx); 169 + if (!updatedGallery) return ctx.next(); 170 + 171 + return ctx.html( 172 + <> 173 + <GalleryEditPhotosDialog 174 + galleryUri={gallery.uri} 175 + photos={updatedGallery?.items 176 + ?.filter(isPhotoView) ?? []} 177 + /> 178 + <div hx-swap-oob="beforeend:#gallery-container"> 179 + {updatedGallery.items?.filter(isPhotoView).filter((i) => 180 + uris.includes(i.uri) 181 + ).map((item) => ( 182 + <GalleryLayout.Item 183 + key={item.uri} 184 + photo={item} 185 + gallery={updatedGallery} 186 + /> 187 + ))} 188 + </div> 189 + <div hx-swap-oob="outerHTML:#gallery-info"> 190 + <GalleryInfo gallery={updatedGallery} /> 191 + </div> 192 + </>, 193 + ); 135 194 }; 136 195 137 196 export const galleryAddPhoto: RouteHandler = async ( 138 - _req, 197 + req, 139 198 params, 140 199 ctx: BffContext<State>, 141 200 ) => { 142 201 const { did } = ctx.requireAuth(); 202 + const url = new URL(req.url); 203 + const page = url.searchParams.get("page") || undefined; 143 204 const galleryRkey = params.galleryRkey; 144 205 const photoRkey = params.photoRkey; 145 206 const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; 146 207 const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 147 208 const gallery = getGallery(did, galleryRkey, ctx); 148 209 const photo = getPhoto(photoUri, ctx); 210 + 149 211 if (!gallery || !photo) return ctx.next(); 212 + 150 213 if ( 151 214 gallery.items 152 215 ?.filter(isPhotoView) 153 216 .some((item) => item.uri === photoUri) 154 217 ) { 218 + if (page === "upload") { 219 + return ctx.redirect(uploadPageLink(galleryRkey)); 220 + } 155 221 return new Response(null, { status: 500 }); 156 222 } 157 - await ctx.createRecord<Gallery>("social.grain.gallery.item", { 223 + 224 + await ctx.createRecord<GalleryItem>("social.grain.gallery.item", { 158 225 gallery: galleryUri, 159 226 item: photoUri, 160 227 position: gallery.items?.length ?? 0, 161 228 createdAt: new Date().toISOString(), 162 229 }); 163 - gallery.items = [ 164 - ...(gallery.items ?? []), 165 - photo, 166 - ]; 230 + 231 + if (page === "upload") { 232 + return ctx.redirect(uploadPageLink(galleryRkey)); 233 + } 234 + 167 235 return ctx.html( 168 236 <> 169 237 <div hx-swap-oob="beforeend:#gallery-container"> ··· 178 246 </div> 179 247 <PhotoSelectButton 180 248 galleryUri={galleryUri} 181 - itemUris={gallery.items?.filter(isPhotoView).map((item) => item.uri) ?? 182 - []} 183 249 photo={photo} 184 250 /> 185 251 </>, ··· 187 253 }; 188 254 189 255 export const galleryRemovePhoto: RouteHandler = async ( 190 - _req, 256 + req, 191 257 params, 192 258 ctx: BffContext<State>, 193 259 ) => { 194 260 const { did } = ctx.requireAuth(); 261 + const url = new URL(req.url); 262 + const selectedGallery = url.searchParams.get("selectedGallery"); 195 263 const galleryRkey = params.galleryRkey; 196 264 const photoRkey = params.photoRkey; 197 265 const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; ··· 225 293 <div hx-swap-oob="outerHTML:#gallery-info"> 226 294 <GalleryInfo gallery={gallery} /> 227 295 </div> 228 - <PhotoSelectButton 229 - galleryUri={galleryUri} 230 - itemUris={gallery.items?.filter(isPhotoView).map((item) => item.uri) ?? 231 - []} 232 - photo={photoToView(photo.did, photo)} 233 - /> 296 + {/* Remove from gallery container or image previews */} 297 + {selectedGallery 298 + ? <div hx-swap-oob={`delete:#photo-${photoRkey}`} /> 299 + : null} 234 300 </>, 235 301 ); 236 302 }; ··· 257 323 }; 258 324 259 325 export const photoDelete: RouteHandler = async ( 260 - _req, 326 + req, 261 327 params, 262 328 ctx: BffContext<State>, 263 329 ) => { 264 330 const { did } = ctx.requireAuth(); 331 + const url = new URL(req.url); 332 + const selectedGallery = url.searchParams.get("selectedGallery"); 333 + const selectedGalleryRkey = selectedGallery 334 + ? new AtUri(selectedGallery).rkey 335 + : undefined; 265 336 const deleteUris: string[] = []; 266 337 await ctx.deleteRecord( 267 338 `at://${did}/social.grain.photo/${params.rkey}`, ··· 317 388 for (const uri of deleteUris) { 318 389 await ctx.deleteRecord(uri); 319 390 } 320 - return new Response(null, { status: 200 }); 391 + return ctx.redirect(uploadPageLink(selectedGalleryRkey)); 321 392 }; 322 393 323 394 export const galleryFavorite: RouteHandler = async ( ··· 517 588 const width = Number(formData.get("width")) || undefined; 518 589 const height = Number(formData.get("height")) || undefined; 519 590 const exifJsonString = formData.get("exif") as string; 591 + const galleryUri = formData.get("galleryUri") as string || undefined; 592 + const page = formData.get("page") as string || undefined; 520 593 let exif = undefined; 521 594 522 595 if (exifJsonString) { ··· 570 643 return new Response("Photo not found after creation", { status: 404 }); 571 644 } 572 645 646 + let gallery: GalleryView | undefined = undefined; 647 + if (galleryUri) { 648 + gallery = getGallery(did, new AtUri(galleryUri).rkey, ctx) ?? undefined; 649 + if (gallery) { 650 + await ctx.createRecord<GalleryItem>("social.grain.gallery.item", { 651 + gallery: galleryUri, 652 + item: photoUri, 653 + position: gallery.items?.length ?? 0, 654 + createdAt: new Date().toISOString(), 655 + }); 656 + } 657 + } 658 + 573 659 let exifRecord: WithBffMeta<PhotoExif> | undefined = undefined; 574 660 if (exifUri) { 575 661 exifRecord = ctx.indexService.getRecord<WithBffMeta<PhotoExif>>( ··· 577 663 ); 578 664 } 579 665 666 + if (page === "gallery" && gallery && galleryUri) { 667 + const rkey = new AtUri(gallery.uri).rkey; 668 + // updated gallery post gallery item creation 669 + const updatedGallery = getGallery(did, rkey, ctx); 670 + if (!updatedGallery) { 671 + return ctx.next(); 672 + } 673 + const p = photoToView(did, photo, exifRecord); 674 + return ctx.html( 675 + <> 676 + <PhotoSelectButton 677 + galleryUri={gallery.uri} 678 + photo={p} 679 + /> 680 + <div hx-swap-oob="beforeend:#gallery-container"> 681 + <GalleryLayout.Item 682 + key={photo.cid} 683 + photo={p} 684 + gallery={updatedGallery} 685 + /> 686 + </div> 687 + <div hx-swap-oob="outerHTML:#gallery-info"> 688 + <GalleryInfo gallery={updatedGallery} /> 689 + </div> 690 + </>, 691 + ); 692 + } 693 + 694 + // @TODO: Use count queries 695 + let photosCount = 0; 696 + if (gallery && galleryUri) { 697 + const rkey = new AtUri(gallery.uri).rkey; 698 + const updatedGallery = getGallery(did, rkey, ctx); 699 + photosCount = updatedGallery?.items?.length ?? 0; 700 + } else { 701 + const photos = getActorPhotos(did, ctx); 702 + photosCount = photos.length; 703 + } 704 + 580 705 return ctx.html( 581 - <PhotoPreview 582 - photo={photoToView(did, photo, exifRecord)} 583 - />, 706 + <> 707 + <PhotoPreview 708 + photo={photoToView(did, photo, exifRecord)} 709 + selectedGallery={gallery} 710 + /> 711 + <div hx-swap-oob="photos-count">{photosCount}</div> 712 + </>, 584 713 ); 585 714 } catch (e) { 586 715 console.error("Error in uploadStart:", e);
+113 -17
src/routes/dialogs.tsx
··· 1 1 import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 2 - import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 3 2 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 4 3 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 5 4 import { AtUri } from "@atproto/syntax"; ··· 7 6 import { wrap } from "popmotion"; 8 7 import { AvatarDialog } from "../components/AvatarDialog.tsx"; 9 8 import { CreateAccountDialog } from "../components/CreateAccountDialog.tsx"; 9 + import { EditGalleryDialog } from "../components/EditGalleryDialog.tsx"; 10 10 import { ExifInfoDialog } from "../components/ExifInfoDialog.tsx"; 11 11 import { ExifOverlayDialog } from "../components/ExifOverlayDialog.tsx"; 12 - import { GalleryCreateEditDialog } from "../components/GalleryCreateEditDialog.tsx"; 12 + import { GalleryDetailsDialog } from "../components/GalleryDetailsDialog.tsx"; 13 + import { GalleryEditPhotosDialog } from "../components/GalleryEditPhotosDialog.tsx"; 14 + import { 15 + GallerySelectDialog, 16 + GallerySelectDialogSearchResults, 17 + } from "../components/GallerySelectDialog.tsx"; 13 18 import { GallerySortDialog } from "../components/GallerySortDialog.tsx"; 14 19 import { LabelDefinitionDialog } from "../components/LabelDefinitionDialog.tsx"; 20 + import { LibraryPhotoSelectDialog } from "../components/LibraryPhotoSelectDialog.tsx"; 15 21 import { PhotoAltDialog } from "../components/PhotoAltDialog.tsx"; 16 22 import { PhotoDialog } from "../components/PhotoDialog.tsx"; 17 23 import { PhotoExifDialog } from "../components/PhotoExifDialog.tsx"; 18 - import { PhotoSelectDialog } from "../components/PhotoSelectDialog.tsx"; 19 24 import { ProfileDialog } from "../components/ProfileDialog.tsx"; 20 - import { getActorPhotos, getActorProfile } from "../lib/actor.ts"; 21 - import { getGallery, getGalleryItemsAndPhotos } from "../lib/gallery.ts"; 25 + import { RemovePhotoDialog } from "../components/RemovePhotoDialog.tsx"; 26 + import { 27 + getActorGalleries, 28 + getActorPhotos, 29 + getActorProfile, 30 + } from "../lib/actor.ts"; 31 + import { getGallery, queryGalleriesByName } from "../lib/gallery.ts"; 22 32 import { atprotoLabelValueDefinitions } from "../lib/moderation.ts"; 23 - import { getPhoto, photoToView } from "../lib/photo.ts"; 33 + import { getPhoto, getPhotoGalleries, photoToView } from "../lib/photo.ts"; 24 34 import type { State } from "../state.ts"; 25 35 26 36 export const createGallery: RouteHandler = ( ··· 29 39 ctx: BffContext<State>, 30 40 ) => { 31 41 ctx.requireAuth(); 32 - return ctx.html(<GalleryCreateEditDialog />); 42 + return ctx.html(<GalleryDetailsDialog />); 33 43 }; 34 44 35 45 export const editGallery: RouteHandler = ( ··· 40 50 const { handle } = ctx.requireAuth(); 41 51 const rkey = params.rkey; 42 52 const gallery = getGallery(handle, rkey, ctx); 43 - return ctx.html(<GalleryCreateEditDialog gallery={gallery} />); 53 + if (!gallery) return ctx.next(); 54 + return ctx.html(<EditGalleryDialog gallery={gallery} />); 55 + }; 56 + 57 + export const editGalleryDetails: RouteHandler = ( 58 + _req, 59 + params, 60 + ctx: BffContext<State>, 61 + ) => { 62 + const { handle } = ctx.requireAuth(); 63 + const rkey = params.rkey; 64 + const gallery = getGallery(handle, rkey, ctx); 65 + return ctx.html(<GalleryDetailsDialog gallery={gallery} />); 44 66 }; 45 67 46 68 export const sortGallery: RouteHandler = ( ··· 172 194 ctx: BffContext<State>, 173 195 ) => { 174 196 const { did } = ctx.requireAuth(); 175 - const photos = getActorPhotos(did, ctx); 176 - const galleryUri = `at://${did}/social.grain.gallery/${params.galleryRkey}`; 177 - const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 178 - galleryUri, 197 + const galleryUri = `at://${did}/social.grain.gallery/${params.rkey}`; 198 + const gallery = getGallery(did, params.rkey, ctx); 199 + if (!gallery) return ctx.next(); 200 + return ctx.html( 201 + <GalleryEditPhotosDialog 202 + galleryUri={galleryUri} 203 + photos={gallery?.items 204 + ?.filter(isPhotoView) ?? []} 205 + />, 179 206 ); 207 + }; 208 + 209 + export const galleryAddFromLibrary: RouteHandler = ( 210 + _req, 211 + params, 212 + ctx: BffContext<State>, 213 + ) => { 214 + const { did } = ctx.requireAuth(); 215 + const galleryUri = `at://${did}/social.grain.gallery/${params.rkey}`; 216 + const gallery = getGallery(did, params.rkey, ctx); 180 217 if (!gallery) return ctx.next(); 181 - const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]); 182 - const itemUris = 183 - galleryPhotosMap.get(galleryUri)?.map((photo) => photo.uri) ?? []; 218 + const galleryPhotoUris = new Set( 219 + gallery.items?.filter(isPhotoView).map((item) => item.uri), 220 + ); 221 + const photos = getActorPhotos(did, ctx).filter((photo) => { 222 + return !galleryPhotoUris.has(photo.uri); 223 + }); 184 224 return ctx.html( 185 - <PhotoSelectDialog 225 + <LibraryPhotoSelectDialog 186 226 galleryUri={galleryUri} 187 - itemUris={itemUris} 188 227 photos={photos} 189 228 />, 190 229 ); ··· 235 274 <ExifInfoDialog />, 236 275 ); 237 276 }; 277 + 278 + export const gallerySelect: RouteHandler = ( 279 + req, 280 + _params, 281 + ctx: BffContext<State>, 282 + ) => { 283 + const { did } = ctx.requireAuth(); 284 + const url = new URL(req.url); 285 + const photoUri = url.searchParams.get("photoUri") as string || undefined; 286 + const galleries = getActorGalleries(did, ctx); 287 + const query = url.searchParams.get("q"); 288 + 289 + if (query) { 290 + const galleries = queryGalleriesByName(did, query, ctx); 291 + return ctx.html( 292 + <GallerySelectDialogSearchResults galleries={galleries} />, 293 + ); 294 + } 295 + 296 + if (query === "") { 297 + // no-op keep the original dialog open 298 + return ctx.html(<GallerySelectDialogSearchResults galleries={galleries} />); 299 + } 300 + 301 + return ctx.html( 302 + <GallerySelectDialog 303 + photoUri={photoUri} 304 + userDid={did} 305 + galleries={galleries ?? []} 306 + />, 307 + ); 308 + }; 309 + 310 + export const photoRemove: RouteHandler = ( 311 + req, 312 + params, 313 + ctx: BffContext<State>, 314 + ) => { 315 + const { did } = ctx.requireAuth(); 316 + const url = new URL(req.url); 317 + const selectedGalleryUri = url.searchParams.get("selectedGallery"); 318 + const rkey = params.rkey; 319 + const photoUri = `at://${did}/social.grain.photo/${rkey}`; 320 + const galleries = getPhotoGalleries(photoUri, ctx); 321 + 322 + const selectedGallery = selectedGalleryUri 323 + ? galleries.find((gallery) => gallery.uri === selectedGalleryUri) 324 + : undefined; 325 + 326 + return ctx.html( 327 + <RemovePhotoDialog 328 + photoUri={photoUri} 329 + galleries={galleries ?? []} 330 + selectedGallery={selectedGallery} 331 + />, 332 + ); 333 + };
+1 -2
src/routes/explore.tsx
··· 2 2 import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 3 import { Un$Typed } from "$lexicon/util.ts"; 4 4 import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 5 - import { Input } from "@bigmoves/bff/components"; 6 5 import { ComponentChildren } from "preact"; 7 6 import { ActorAvatar } from "../components/ActorAvatar.tsx"; 7 + import { Input } from "../components/Input.tsx"; 8 8 import { LabelerAvatar } from "../components/LabelerAvatar.tsx"; 9 9 import { profileToView } from "../lib/actor.ts"; 10 10 import { getPageMeta } from "../meta.ts"; ··· 54 54 <div class="my-4"> 55 55 <Input 56 56 name="q" 57 - class="dark:bg-zinc-800 dark:text-white border-zinc-100 bg-zinc-100 dark:border-zinc-800" 58 57 placeholder="Search for users" 59 58 hx-get="/explore" 60 59 hx-target="#search-results"
+11 -2
src/routes/upload.tsx
··· 1 1 import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 2 import { UploadPage } from "../components/UploadPage.tsx"; 3 3 import { getActorPhotos } from "../lib/actor.ts"; 4 + import { getGallery, getGalleryPhotos } from "../lib/gallery.ts"; 4 5 import { getPageMeta } from "../meta.ts"; 5 6 import type { State } from "../state.ts"; 6 7 import { galleryLink } from "../utils.ts"; ··· 12 13 ) => { 13 14 const { did, handle } = ctx.requireAuth(); 14 15 const url = new URL(req.url); 16 + const selectedGalleryRkey = url.searchParams.get("gallery") ?? ""; 17 + const selectedGalleryUri = 18 + `at://${did}/social.grain.gallery/${selectedGalleryRkey}`; 15 19 const galleryRkey = url.searchParams.get("returnTo"); 16 - const photos = getActorPhotos(did, ctx); 20 + const photos = selectedGalleryRkey 21 + ? getGalleryPhotos(selectedGalleryUri, ctx) 22 + : getActorPhotos(did, ctx); 23 + const selectedGallery = getGallery(did, selectedGalleryRkey, ctx); 17 24 ctx.state.meta = [{ title: "Upload — Grain" }, ...getPageMeta("/upload")]; 18 25 return ctx.render( 19 26 <UploadPage 20 - handle={handle} 27 + userDid={did} 28 + userHandle={handle} 21 29 photos={photos} 22 30 returnTo={galleryRkey ? galleryLink(handle, galleryRkey) : undefined} 31 + selectedGallery={selectedGallery ?? undefined} 23 32 />, 24 33 ); 25 34 };
+54
src/static/exif.ts
··· 1 + export const tags = [ 2 + "DateTimeOriginal", 3 + "ExposureTime", 4 + "FNumber", 5 + "Flash", 6 + "FocalLengthIn35mmFormat", 7 + "ISO", 8 + "LensMake", 9 + "LensModel", 10 + "Make", 11 + "Model", 12 + ]; 13 + 14 + export const SCALE_FACTOR = 1000000; 15 + 16 + export type Exif = Record< 17 + string, 18 + number | string | boolean | Array<number | string> | undefined | Date 19 + >; 20 + 21 + export function normalizeExif( 22 + exif: Exif, 23 + scale: number = SCALE_FACTOR, 24 + ): Exif { 25 + const normalized: Record< 26 + string, 27 + number | string | boolean | Array<number | string> | undefined 28 + > = {}; 29 + 30 + for (const [key, value] of Object.entries(exif)) { 31 + const camelKey = key[0].toLowerCase() + key.slice(1); 32 + 33 + if (typeof value === "number") { 34 + normalized[camelKey] = Math.round(value * scale); 35 + } else if (Array.isArray(value)) { 36 + normalized[camelKey] = value.map((v) => 37 + typeof v === "number" ? Math.round(v * scale) : v 38 + ); 39 + } else if (value instanceof Date) { 40 + normalized[camelKey] = value.toISOString(); 41 + } else if (typeof value === "string") { 42 + normalized[camelKey] = value; 43 + } else if (typeof value === "boolean") { 44 + normalized[camelKey] = value; 45 + } else if (value === undefined) { 46 + normalized[camelKey] = undefined; 47 + } else { 48 + // fallback for unknown types 49 + normalized[camelKey] = String(value); 50 + } 51 + } 52 + 53 + return normalized; 54 + }
+137
src/static/gallery_photos_dialog.ts
··· 1 + import { 2 + dataURLToBlob, 3 + doResize, 4 + readFileAsDataURL, 5 + } from "@bigmoves/bff/browser"; 6 + import exifr from "exifr"; 7 + import htmx from "htmx.org"; 8 + import { Exif, normalizeExif, tags as supportedTags } from "./exif.ts"; 9 + 10 + export class GalleryPhotosDialog { 11 + public async uploadPhotos(formElement: HTMLFormElement): Promise<void> { 12 + const formData = new FormData(formElement); 13 + const fileList = formData.getAll("files") as File[] ?? []; 14 + const parseExif = formData.get("parseExif") === "on"; 15 + const galleryUri = formData.get("galleryUri") as string; 16 + const page = formData.get("page") as string; 17 + 18 + if (fileList.length > 10) { 19 + alert("You can only upload 10 photos at a time"); 20 + return; 21 + } 22 + 23 + const uploadPromises = fileList.map(async (file) => { 24 + let fileDataUri: string | ArrayBuffer | null; 25 + let tags: Exif | undefined = undefined; 26 + let resized; 27 + 28 + try { 29 + fileDataUri = await readFileAsDataURL(file); 30 + if (fileDataUri === null || typeof fileDataUri !== "string") { 31 + console.error("File data URL is not a string:", fileDataUri); 32 + alert("Error reading file."); 33 + return; 34 + } 35 + } catch (err) { 36 + console.error("Error reading file as Data URL:", err); 37 + alert("Error reading file."); 38 + return; 39 + } 40 + 41 + if (parseExif) { 42 + try { 43 + const rawTags = await exifr.parse(file, { pick: supportedTags }); 44 + console.log("EXIF tags:", await exifr.parse(file)); 45 + tags = normalizeExif(rawTags); 46 + } catch (err) { 47 + console.error("Error reading EXIF data:", err); 48 + } 49 + } 50 + 51 + try { 52 + resized = await doResize(fileDataUri, { 53 + width: 2000, 54 + height: 2000, 55 + maxSize: 1000 * 1000, // 1MB 56 + mode: "contain", 57 + }); 58 + } catch (err) { 59 + console.error("Error resizing image:", err); 60 + alert("Error resizing image."); 61 + return; 62 + } 63 + 64 + const blob = dataURLToBlob(resized.path); 65 + 66 + const fd = new FormData(); 67 + fd.append("file", blob, file.name); 68 + fd.append("width", String(resized.width)); 69 + fd.append("height", String(resized.height)); 70 + 71 + if (tags) { 72 + fd.append("exif", JSON.stringify(tags)); 73 + } 74 + 75 + if (galleryUri) { 76 + fd.append("galleryUri", galleryUri); 77 + } 78 + 79 + if (page) { 80 + fd.append("page", page); 81 + } 82 + 83 + const response = await fetch(`/actions/photo`, { 84 + method: "POST", 85 + body: fd, 86 + }); 87 + 88 + if (!response.ok) { 89 + alert(await response.text()); 90 + return; 91 + } 92 + 93 + const html = await response.text(); 94 + const temp = document.createElement("div"); 95 + temp.innerHTML = html; 96 + 97 + const preview = document.querySelector("#image-preview"); 98 + if (preview) { 99 + const child = temp.firstElementChild; 100 + if (child) { 101 + preview.appendChild(child); 102 + } 103 + htmx.process(preview); 104 + } 105 + 106 + const galleryContainer = document.querySelector( 107 + "#gallery-container", 108 + ); 109 + if (galleryContainer) { 110 + const child = temp.firstElementChild; 111 + if (child) { 112 + galleryContainer.appendChild(child.children[0]); 113 + } 114 + htmx.process(galleryContainer); 115 + } 116 + 117 + const galleryInfo = document.querySelector( 118 + "#gallery-info", 119 + ); 120 + if (galleryInfo) { 121 + const child = temp.children[1]; 122 + if (child) { 123 + galleryInfo.replaceWith(child.children[0]); 124 + } 125 + htmx.process(galleryInfo); 126 + } 127 + }); 128 + 129 + await Promise.all(uploadPromises); 130 + 131 + // Clear the file input after upload 132 + const fileInput = formElement.querySelector("input[type='file']"); 133 + if (fileInput instanceof HTMLInputElement) { 134 + fileInput.value = ""; 135 + } 136 + } 137 + }
+7 -1
src/static/mod.ts
··· 2 2 import _hyperscript from "hyperscript.org"; 3 3 import Sortable from "sortablejs"; 4 4 import { GalleryLayout } from "./gallery_layout.ts"; 5 + import { GalleryPhotosDialog } from "./gallery_photos_dialog.ts"; 5 6 import { PhotoDialog } from "./photo_dialog.ts"; 6 7 import { ProfileDialog } from "./profile_dialog.ts"; 7 8 import { UploadPage } from "./upload_page.ts"; 8 9 9 - const galleryLayout = new GalleryLayout({ layoutMode: "justified" }); 10 + const galleryLayout = new GalleryLayout({ 11 + layoutMode: "justified", 12 + spacing: 4, 13 + }); 10 14 galleryLayout.init(); 11 15 12 16 htmx.onLoad(function (element) { ··· 31 35 uploadPage?: UploadPage; 32 36 profileDialog?: ProfileDialog; 33 37 galleryLayout?: GalleryLayout; 38 + galleryPhotosDialog?: GalleryPhotosDialog; 34 39 }; 35 40 }; 36 41 ··· 40 45 g.Grain = g.Grain ?? {}; 41 46 g.Grain.uploadPage = new UploadPage(); 42 47 g.Grain.profileDialog = new ProfileDialog(); 48 + g.Grain.galleryPhotosDialog = new GalleryPhotosDialog(); 43 49 g.Grain.galleryLayout = galleryLayout;
-12
src/static/tags.ts
··· 1 - export const tags = [ 2 - "DateTimeOriginal", 3 - "ExposureTime", 4 - "FNumber", 5 - "Flash", 6 - "FocalLengthIn35mmFormat", 7 - "ISO", 8 - "LensMake", 9 - "LensModel", 10 - "Make", 11 - "Model", 12 - ];
+18 -48
src/static/upload_page.ts
··· 6 6 import exifr from "exifr"; 7 7 import htmx from "htmx.org"; 8 8 import hyperscript from "hyperscript.org"; 9 - import { tags as supportedTags } from "./tags.ts"; 9 + import { Exif, normalizeExif, tags as supportedTags } from "./exif.ts"; 10 10 11 11 export class UploadPage { 12 12 public async uploadPhotos(formElement: HTMLFormElement): Promise<void> { 13 13 const formData = new FormData(formElement); 14 14 const fileList = formData.getAll("files") as File[] ?? []; 15 15 const parseExif = formData.get("parseExif") === "on"; 16 + const galleryUri = formData.get("galleryUri") as string; 16 17 17 18 if (fileList.length > 10) { 18 19 alert("You can only upload 10 photos at a time"); ··· 63 64 const blob = dataURLToBlob(resized.path); 64 65 65 66 const fd = new FormData(); 66 - fd.append("file", blob, (file as File).name); 67 + fd.append("file", blob, file.name); 67 68 fd.append("width", String(resized.width)); 68 69 fd.append("height", String(resized.height)); 69 70 70 71 if (tags) { 71 72 fd.append("exif", JSON.stringify(tags)); 73 + } 74 + 75 + if (galleryUri) { 76 + fd.append("galleryUri", galleryUri); 72 77 } 73 78 74 79 const response = await fetch("/actions/photo", { ··· 99 104 const deleteButton = preview.querySelector( 100 105 `#delete-photo-${photoId}`, 101 106 ); 102 - if (!deleteButton) { 103 - return; 107 + if (deleteButton) { 108 + htmx.process(deleteButton); 109 + hyperscript.processNode(deleteButton); 110 + } 111 + } 112 + 113 + const photosCount = document.querySelector("#photos-count"); 114 + if (photosCount) { 115 + const firstChild = temp.firstElementChild; 116 + if (firstChild) { 117 + photosCount.replaceWith(firstChild.innerHTML); 104 118 } 105 - htmx.process(deleteButton); 106 - hyperscript.processNode(deleteButton); 107 119 } 108 120 }); 109 121 ··· 116 128 } 117 129 } 118 130 } 119 - 120 - const SCALE_FACTOR = 1000000; 121 - 122 - type Exif = Record< 123 - string, 124 - number | string | boolean | Array<number | string> | undefined | Date 125 - >; 126 - 127 - function normalizeExif( 128 - exif: Exif, 129 - scale: number = SCALE_FACTOR, 130 - ): Exif { 131 - const normalized: Record< 132 - string, 133 - number | string | boolean | Array<number | string> | undefined 134 - > = {}; 135 - 136 - for (const [key, value] of Object.entries(exif)) { 137 - const camelKey = key[0].toLowerCase() + key.slice(1); 138 - 139 - if (typeof value === "number") { 140 - normalized[camelKey] = Math.round(value * scale); 141 - } else if (Array.isArray(value)) { 142 - normalized[camelKey] = value.map((v) => 143 - typeof v === "number" ? Math.round(v * scale) : v 144 - ); 145 - } else if (value instanceof Date) { 146 - normalized[camelKey] = value.toISOString(); 147 - } else if (typeof value === "string") { 148 - normalized[camelKey] = value; 149 - } else if (typeof value === "boolean") { 150 - normalized[camelKey] = value; 151 - } else if (value === undefined) { 152 - normalized[camelKey] = undefined; 153 - } else { 154 - // fallback for unknown types 155 - normalized[camelKey] = String(value); 156 - } 157 - } 158 - 159 - return normalized; 160 - }
+5
src/utils.ts
··· 27 27 return `${Math.max(1, minutes)}m`; 28 28 } 29 29 30 + export function uploadPageLink(selectedGalleryRkey?: string) { 31 + return "/upload" + 32 + (selectedGalleryRkey ? "?gallery=" + selectedGalleryRkey : ""); 33 + } 34 + 30 35 export function profileLink(handle: string) { 31 36 return `/profile/${handle}`; 32 37 }
+1 -1
sync.sh
··· 2 2 3 3 # Helpful when running local-infra. Specify the repos you've created on a local pds instance. 4 4 5 - REPOS="did:plc:gdvspmipkels2qp43m4czqhp" 5 + REPOS="did:plc:yyz2m2gxnbaxoru2sepbltxv" 6 6 COLLECTIONS="social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif" 7 7 EXTERNAL_COLLECTIONS="app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile" 8 8