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

finally break up main.tsx into separate files

+16
.editorconfig
··· 1 + root = true 2 + 3 + [*] 4 + end_of_line = lf 5 + charset = utf-8 6 + trim_trailing_whitespace = true 7 + insert_final_newline = true 8 + indent_style = space 9 + indent_size = 2 10 + 11 + [*.txt] 12 + indent_style = tab 13 + indent_size = 4 14 + 15 + [*.{diff,md}] 16 + trim_trailing_whitespace = false
+2 -2
Dockerfile
··· 6 6 7 7 # Give ownership to deno user and cache dependencies 8 8 RUN chown -R deno:deno /app && \ 9 - deno cache ./main.tsx 9 + deno cache ./src/main.tsx 10 10 11 11 FROM denoland/deno:alpine-2.2.3 12 12 ··· 26 26 27 27 # Run LiteFS as the entrypoint. After it has connected and sync'd with the 28 28 # cluster, it will run the commands listed in the "exec" field of the config. 29 - ENTRYPOINT ["litefs", "mount"] 29 + ENTRYPOINT ["litefs", "mount"]
+3 -4
deno.json
··· 14 14 "typed-htmx": "npm:typed-htmx@^0.3.1" 15 15 }, 16 16 "tasks": { 17 - "start": "deno run -A --unstable-kv --unstable-ffi main.tsx", 17 + "start": "deno run -A --unstable-kv --unstable-ffi ./src/main.tsx", 18 18 "dev": "deno run \"dev:*\"", 19 - "dev:server": "deno run -A --unstable-kv --unstable-ffi --env-file=.env --watch ./main.tsx", 20 - "dev:tailwind": "deno run -A --node-modules-dir npm:@tailwindcss/cli -i ./input.css -o ./static/styles.css --watch", 21 - "codegen": "deno run -A ../../packages/bff-cli/mod.ts lex" 19 + "dev:server": "deno run -A --unstable-kv --unstable-ffi --env-file=.env --watch ./src/main.tsx", 20 + "dev:tailwind": "deno run -A --node-modules-dir npm:@tailwindcss/cli -i ./src/input.css -o ./static/styles.css --watch" 22 21 }, 23 22 "compilerOptions": { 24 23 "jsx": "precompile",
input.css src/input.css
+1 -1
litefs.yml
··· 32 32 # the last command to be long-running (e.g. an application server). When the 33 33 # last command exits, LiteFS is shut down. 34 34 exec: 35 - - cmd: "deno run -A --unstable-kv --unstable-ffi main.tsx" 35 + - cmd: "deno run start" 36 36 37 37 # The lease section specifies how the cluster will be managed. We're using the 38 38 # "consul" lease type so that our application can dynamically change the primary.
-2846
main.tsx
··· 1 - import { lexicons } from "$lexicon/lexicons.ts"; 2 - import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts"; 3 - import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 4 - import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 5 - import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 6 - import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 7 - import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 8 - import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 9 - import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 10 - import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 11 - import { 12 - isRecord as isPhoto, 13 - Record as Photo, 14 - } from "$lexicon/types/social/grain/photo.ts"; 15 - import { 16 - isPhotoView, 17 - PhotoView, 18 - } from "$lexicon/types/social/grain/photo/defs.ts"; 19 - import { $Typed, Un$Typed } from "$lexicon/util.ts"; 20 - import { AtUri } from "@atproto/syntax"; 21 - import { 22 - ActorTable, 23 - bff, 24 - BffContext, 25 - BffMiddleware, 26 - CSS, 27 - JETSTREAM, 28 - oauth, 29 - OAUTH_ROUTES, 30 - onSignedInArgs, 31 - RateLimitError, 32 - RootProps, 33 - route, 34 - RouteHandler, 35 - UnauthorizedError, 36 - WithBffMeta, 37 - } from "@bigmoves/bff"; 38 - import { BFFPhotoProcessor } from "@bigmoves/bff-photo-processor"; 39 - import { 40 - Button, 41 - cn, 42 - Dialog, 43 - Input, 44 - Layout, 45 - Login, 46 - Meta, 47 - type MetaDescriptor, 48 - Textarea, 49 - } from "@bigmoves/bff/components"; 50 - import { createCanvas, Image } from "@gfx/canvas"; 51 - import { join } from "@std/path"; 52 - import { 53 - differenceInDays, 54 - differenceInHours, 55 - differenceInMinutes, 56 - differenceInWeeks, 57 - formatDuration, 58 - intervalToDuration, 59 - } from "date-fns"; 60 - import { wrap } from "popmotion"; 61 - import { ComponentChildren, JSX, VNode } from "preact"; 62 - 63 - const PUBLIC_URL = Deno.env.get("BFF_PUBLIC_URL") ?? "http://localhost:8080"; 64 - const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 65 - 66 - const staticFilesHash = new Map<string, string>(); 67 - 68 - const photoProcessor = new BFFPhotoProcessor(); 69 - 70 - bff({ 71 - appName: "Grain Social", 72 - collections: [ 73 - "social.grain.gallery", 74 - "social.grain.actor.profile", 75 - "social.grain.photo", 76 - "social.grain.favorite", 77 - "social.grain.gallery.item", 78 - ], 79 - jetstreamUrl: JETSTREAM.WEST_1, 80 - lexicons, 81 - rootElement: Root, 82 - onListen: async () => { 83 - for (const entry of Deno.readDirSync(join(Deno.cwd(), "static"))) { 84 - if ( 85 - entry.isFile && 86 - (entry.name.endsWith(".js") || entry.name.endsWith(".css")) 87 - ) { 88 - const fileContent = await Deno.readFile( 89 - join(Deno.cwd(), "static", entry.name), 90 - ); 91 - const hashBuffer = await crypto.subtle.digest("SHA-256", fileContent); 92 - const hash = Array.from(new Uint8Array(hashBuffer)) 93 - .map((b) => b.toString(16).padStart(2, "0")) 94 - .join(""); 95 - staticFilesHash.set(entry.name, hash); 96 - } 97 - } 98 - }, 99 - onError: (err) => { 100 - if (err instanceof UnauthorizedError) { 101 - const ctx = err.ctx; 102 - return ctx.redirect(OAUTH_ROUTES.loginPage); 103 - } 104 - if (err instanceof RateLimitError) { 105 - const now = new Date(); 106 - const future = new Date(now.getTime() + (err.retryAfter ?? 0) * 1000); 107 - const duration = intervalToDuration({ start: now, end: future }); 108 - const formatted = formatDuration(duration, { 109 - format: ["minutes", "seconds"], 110 - }); 111 - return new Response( 112 - `Too many requests. Retry in ${formatted}.`, 113 - { 114 - status: 429, 115 - headers: { 116 - ...err.retryAfter && { "Retry-After": err.retryAfter.toString() }, 117 - "Content-Type": "text/plain", 118 - }, 119 - }, 120 - ); 121 - } 122 - return new Response("Internal Server Error", { 123 - status: 500, 124 - }); 125 - }, 126 - middlewares: [ 127 - (req, ctx) => { 128 - if (ctx.currentUser) { 129 - const url = new URL(req.url); 130 - if ( 131 - ["actions", "embed"].some((path) => url.pathname.includes(path)) || 132 - (url.pathname.includes("dialogs") && 133 - !url.pathname.includes("/dialogs/profile")) 134 - ) { 135 - return ctx.next(); 136 - } 137 - const profile = getActorProfile(ctx.currentUser.did, ctx); 138 - if (profile) { 139 - ctx.state.profile = profile; 140 - } 141 - const notifications = getNotifications(ctx.currentUser, ctx); 142 - ctx.state.notifications = notifications; 143 - return ctx.next(); 144 - } 145 - return ctx.next(); 146 - }, 147 - oauth({ 148 - onSignedIn, 149 - LoginComponent: ({ error }) => ( 150 - <div 151 - id="login" 152 - class="flex justify-center items-center w-full h-full relative" 153 - style="background-image: url('https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreiewhwu3ro5dv7omedphb62db4koa7qtvyzfhiiypg3ru4tvuxkrjy@jpeg'); background-size: cover; background-position: center;" 154 - > 155 - <Login hx-target="#login" error={error} errorClass="text-white" /> 156 - <div class="absolute bottom-2 right-2 text-white text-sm"> 157 - Photo by{" "} 158 - <a 159 - href={profileLink("chadtmiller.com")} 160 - class="hover:underline font-semibold" 161 - > 162 - @chadtmiller.com 163 - </a> 164 - </div> 165 - </div> 166 - ), 167 - }), 168 - route("/", (_req, _params, ctx) => { 169 - const items = getTimeline(ctx); 170 - ctx.state.meta = [{ title: "Timeline — Grain" }, getPageMeta("")]; 171 - return ctx.render(<Timeline items={items} />); 172 - }), 173 - route("/notifications", (_req, _params, ctx: BffContext<State>) => { 174 - ctx.requireAuth(); 175 - ctx.state.meta = [ 176 - { title: "Notifications — Grain" }, 177 - ]; 178 - return ctx.render( 179 - <NotificationsPage notifications={ctx.state.notifications ?? []} />, 180 - ); 181 - }), 182 - route("/profile/:handle", (req, params, ctx) => { 183 - const url = new URL(req.url); 184 - const tab = url.searchParams.get("tab"); 185 - const handle = params.handle; 186 - const timelineItems = getActorTimeline(handle, ctx); 187 - const galleries = getActorGalleries(handle, ctx); 188 - const actor = ctx.indexService.getActorByHandle(handle); 189 - if (!actor) return ctx.next(); 190 - const profile = getActorProfile(actor.did, ctx); 191 - if (!profile) return ctx.next(); 192 - let follow: WithBffMeta<BskyFollow> | undefined; 193 - if (ctx.currentUser) { 194 - follow = getFollow(profile.did, ctx.currentUser.did, ctx); 195 - } 196 - ctx.state.meta = [ 197 - { 198 - title: profile.displayName 199 - ? `${profile.displayName} (${profile.handle}) — Grain` 200 - : `${profile.handle} — Grain`, 201 - }, 202 - getPageMeta(profileLink(handle)), 203 - ]; 204 - if (tab) { 205 - return ctx.html( 206 - <ProfilePage 207 - followUri={follow?.uri} 208 - loggedInUserDid={ctx.currentUser?.did} 209 - timelineItems={timelineItems} 210 - profile={profile} 211 - selectedTab={tab} 212 - galleries={galleries} 213 - />, 214 - ); 215 - } 216 - return ctx.render( 217 - <ProfilePage 218 - followUri={follow?.uri} 219 - loggedInUserDid={ctx.currentUser?.did} 220 - timelineItems={timelineItems} 221 - profile={profile} 222 - />, 223 - ); 224 - }), 225 - route( 226 - "/profile/:handle/gallery/:rkey", 227 - (_req, params, ctx: BffContext<State>) => { 228 - const did = ctx.currentUser?.did; 229 - let favs: WithBffMeta<Favorite>[] = []; 230 - const handle = params.handle; 231 - const rkey = params.rkey; 232 - const gallery = getGallery(handle, rkey, ctx); 233 - if (!gallery) return ctx.next(); 234 - favs = getGalleryFavs(gallery.uri, ctx); 235 - ctx.state.meta = [ 236 - { title: `${(gallery.record as Gallery).title} — Grain` }, 237 - ...getPageMeta(galleryLink(handle, rkey)), 238 - ...getGalleryMeta(gallery), 239 - ]; 240 - ctx.state.scripts = ["photo_dialog.js", "masonry.js", "sortable.js"]; 241 - return ctx.render( 242 - <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, 243 - ); 244 - }, 245 - ), 246 - route("/embed/profile/:did/gallery/:rkey", (_req, params, ctx) => { 247 - const gallery = getGallery(params.did, params.rkey, ctx); 248 - if (!gallery) return ctx.next(); 249 - return ctx.html(<GalleryPreviewLink gallery={gallery} size="small" />); 250 - }), 251 - route("/upload", (req, _params, ctx) => { 252 - const { did, handle } = ctx.requireAuth(); 253 - const url = new URL(req.url); 254 - const galleryRkey = url.searchParams.get("returnTo"); 255 - const photos = getActorPhotos(did, ctx); 256 - ctx.state.meta = [{ title: "Upload — Grain" }, getPageMeta("/upload")]; 257 - ctx.state.scripts = ["upload_page.js"]; 258 - return ctx.render( 259 - <UploadPage 260 - handle={handle} 261 - photos={photos} 262 - returnTo={galleryRkey ? galleryLink(handle, galleryRkey) : undefined} 263 - />, 264 - ); 265 - }), 266 - route("/onboard", (_req, _params, ctx) => { 267 - ctx.requireAuth(); 268 - return ctx.render( 269 - <div 270 - hx-get="/dialogs/profile" 271 - hx-trigger="load" 272 - hx-target="body" 273 - hx-swap="afterbegin" 274 - />, 275 - ); 276 - }), 277 - route("/dialogs/gallery/new", (_req, _params, ctx) => { 278 - ctx.requireAuth(); 279 - return ctx.html(<GalleryCreateEditDialog />); 280 - }), 281 - route("/dialogs/gallery/:rkey", (_req, params, ctx) => { 282 - const { handle } = ctx.requireAuth(); 283 - const rkey = params.rkey; 284 - const gallery = getGallery(handle, rkey, ctx); 285 - return ctx.html(<GalleryCreateEditDialog gallery={gallery} />); 286 - }), 287 - route("/dialogs/gallery/:rkey/sort", (_req, params, ctx) => { 288 - const { handle } = ctx.requireAuth(); 289 - const rkey = params.rkey; 290 - const gallery = getGallery(handle, rkey, ctx); 291 - if (!gallery) return ctx.next(); 292 - return ctx.html(<GallerySortDialog gallery={gallery} />); 293 - }), 294 - route("/dialogs/profile", (_req, _params, ctx: BffContext<State>) => { 295 - const { did } = ctx.requireAuth(); 296 - 297 - if (!ctx.state.profile) return ctx.next(); 298 - 299 - const profileRecord = ctx.indexService.getRecord<Profile>( 300 - `at://${did}/social.grain.actor.profile/self`, 301 - ); 302 - 303 - if (!profileRecord) return ctx.next(); 304 - 305 - return ctx.html( 306 - <ProfileDialog 307 - profile={ctx.state.profile} 308 - />, 309 - ); 310 - }), 311 - route("/dialogs/avatar/:handle", (_req, params, ctx) => { 312 - const handle = params.handle; 313 - const actor = ctx.indexService.getActorByHandle(handle); 314 - if (!actor) return ctx.next(); 315 - const profile = getActorProfile(actor.did, ctx); 316 - if (!profile) return ctx.next(); 317 - return ctx.html(<AvatarDialog profile={profile} />); 318 - }), 319 - route("/dialogs/image", (req, _params, ctx) => { 320 - const url = new URL(req.url); 321 - const galleryUri = url.searchParams.get("galleryUri"); 322 - const imageCid = url.searchParams.get("imageCid"); 323 - if (!galleryUri || !imageCid) return ctx.next(); 324 - const atUri = new AtUri(galleryUri); 325 - const galleryDid = atUri.hostname; 326 - const galleryRkey = atUri.rkey; 327 - const gallery = getGallery(galleryDid, galleryRkey, ctx); 328 - if (!gallery?.items) return ctx.next(); 329 - const image = gallery.items.filter(isPhotoView).find((item) => { 330 - return item.cid === imageCid; 331 - }); 332 - const imageAtIndex = gallery.items 333 - .filter(isPhotoView) 334 - .findIndex((image) => { 335 - return image.cid === imageCid; 336 - }); 337 - const next = wrap(0, gallery.items.length, imageAtIndex + 1); 338 - const prev = wrap(0, gallery.items.length, imageAtIndex - 1); 339 - if (!image) return ctx.next(); 340 - return ctx.html( 341 - <PhotoDialog 342 - gallery={gallery} 343 - image={image} 344 - nextImage={gallery.items.filter(isPhotoView).at(next)} 345 - prevImage={gallery.items.filter(isPhotoView).at(prev)} 346 - />, 347 - ); 348 - }), 349 - route("/dialogs/photo/:rkey/alt", (_req, params, ctx) => { 350 - const { did } = ctx.requireAuth(); 351 - const photoRkey = params.rkey; 352 - const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 353 - const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 354 - if (!photo) return ctx.next(); 355 - return ctx.html( 356 - <PhotoAltDialog photo={photoToView(did, photo)} />, 357 - ); 358 - }), 359 - route("/dialogs/photo-select/:galleryRkey", (_req, params, ctx) => { 360 - const { did } = ctx.requireAuth(); 361 - const photos = getActorPhotos(did, ctx); 362 - const galleryUri = 363 - `at://${did}/social.grain.gallery/${params.galleryRkey}`; 364 - const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 365 - galleryUri, 366 - ); 367 - if (!gallery) return ctx.next(); 368 - const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]); 369 - const itemUris = galleryPhotosMap.get(galleryUri)?.map((photo) => 370 - photo.uri 371 - ) ?? []; 372 - return ctx.html( 373 - <PhotoSelectDialog 374 - galleryUri={galleryUri} 375 - itemUris={itemUris} 376 - photos={photos} 377 - />, 378 - ); 379 - }), 380 - route("/actions/update-seen", ["POST"], (_req, _params, ctx) => { 381 - ctx.requireAuth(); 382 - ctx.updateSeen(); 383 - return new Response(null, { status: 200 }); 384 - }), 385 - route("/actions/follow/:did", ["POST"], async (_req, params, ctx) => { 386 - ctx.requireAuth(); 387 - const did = params.did; 388 - if (!did) return ctx.next(); 389 - const followUri = await ctx.createRecord<BskyFollow>( 390 - "app.bsky.graph.follow", 391 - { 392 - subject: did, 393 - createdAt: new Date().toISOString(), 394 - }, 395 - ); 396 - return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 397 - }), 398 - route( 399 - "/actions/follow/:followeeDid/:rkey", 400 - ["DELETE"], 401 - async (_req, params, ctx) => { 402 - const { did } = ctx.requireAuth(); 403 - const followeeDid = params.followeeDid; 404 - const rkey = params.rkey; 405 - await ctx.deleteRecord( 406 - `at://${did}/app.bsky.graph.follow/${rkey}`, 407 - ); 408 - return ctx.html( 409 - <FollowButton followeeDid={followeeDid} followUri={undefined} />, 410 - ); 411 - }, 412 - ), 413 - route("/actions/create-edit", ["POST"], async (req, _params, ctx) => { 414 - const { handle } = ctx.requireAuth(); 415 - const formData = await req.formData(); 416 - const title = formData.get("title") as string; 417 - const description = formData.get("description") as string; 418 - const url = new URL(req.url); 419 - const searchParams = new URLSearchParams(url.search); 420 - const uri = searchParams.get("uri"); 421 - 422 - if (uri) { 423 - const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(uri); 424 - if (!gallery) return ctx.next(); 425 - const rkey = new AtUri(uri).rkey; 426 - try { 427 - await ctx.updateRecord<Gallery>("social.grain.gallery", rkey, { 428 - title, 429 - description, 430 - createdAt: gallery.createdAt, 431 - }); 432 - } catch (e) { 433 - console.error("Error updating record:", e); 434 - const errorMessage = e instanceof Error 435 - ? e.message 436 - : "Unknown error occurred"; 437 - return new Response(errorMessage, { status: 400 }); 438 - } 439 - return ctx.redirect(galleryLink(handle, rkey)); 440 - } 441 - 442 - const createdUri = await ctx.createRecord<Gallery>( 443 - "social.grain.gallery", 444 - { 445 - title, 446 - description, 447 - createdAt: new Date().toISOString(), 448 - }, 449 - ); 450 - return ctx.redirect(galleryLink(handle, new AtUri(createdUri).rkey)); 451 - }), 452 - route("/actions/gallery/delete", ["POST"], async (req, _params, ctx) => { 453 - ctx.requireAuth(); 454 - const formData = await req.formData(); 455 - const uri = formData.get("uri") as string; 456 - await deleteGallery(uri, ctx); 457 - return ctx.redirect("/"); 458 - }), 459 - route( 460 - "/actions/gallery/:galleryRkey/add-photo/:photoRkey", 461 - ["PUT"], 462 - async (_req, params, ctx) => { 463 - const { did } = ctx.requireAuth(); 464 - const galleryRkey = params.galleryRkey; 465 - const photoRkey = params.photoRkey; 466 - const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; 467 - const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 468 - const gallery = getGallery(did, galleryRkey, ctx); 469 - const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 470 - if (!gallery || !photo) return ctx.next(); 471 - if ( 472 - gallery.items 473 - ?.filter(isPhotoView) 474 - .some((item) => item.uri === photoUri) 475 - ) { 476 - return new Response(null, { status: 500 }); 477 - } 478 - await ctx.createRecord<Gallery>("social.grain.gallery.item", { 479 - gallery: galleryUri, 480 - item: photoUri, 481 - position: gallery.items?.length ?? 0, 482 - createdAt: new Date().toISOString(), 483 - }); 484 - gallery.items = [ 485 - ...(gallery.items ?? []), 486 - photoToView(photo.did, photo), 487 - ]; 488 - return ctx.html( 489 - <> 490 - <div hx-swap-oob="beforeend:#masonry-container"> 491 - <PhotoButton 492 - key={photo.cid} 493 - photo={photoToView(photo.did, photo)} 494 - gallery={gallery} 495 - /> 496 - </div> 497 - <PhotoSelectButton 498 - galleryUri={galleryUri} 499 - itemUris={gallery.items?.filter(isPhotoView).map((item) => 500 - item.uri 501 - ) ?? []} 502 - photo={photoToView(photo.did, photo)} 503 - /> 504 - </>, 505 - ); 506 - }, 507 - ), 508 - route( 509 - "/actions/gallery/:galleryRkey/remove-photo/:photoRkey", 510 - ["PUT"], 511 - async (_req, params, ctx) => { 512 - const { did } = ctx.requireAuth(); 513 - const galleryRkey = params.galleryRkey; 514 - const photoRkey = params.photoRkey; 515 - const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; 516 - const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 517 - if (!galleryRkey || !photoRkey) return ctx.next(); 518 - const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 519 - if (!photo) return ctx.next(); 520 - const { 521 - items: [item], 522 - } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 523 - "social.grain.gallery.item", 524 - { 525 - where: [ 526 - { 527 - field: "gallery", 528 - equals: galleryUri, 529 - }, 530 - { 531 - field: "item", 532 - equals: photoUri, 533 - }, 534 - ], 535 - }, 536 - ); 537 - if (!item) return ctx.next(); 538 - await ctx.deleteRecord(item.uri); 539 - const gallery = getGallery(did, galleryRkey, ctx); 540 - if (!gallery) return ctx.next(); 541 - return ctx.html( 542 - <PhotoSelectButton 543 - galleryUri={galleryUri} 544 - itemUris={gallery.items?.filter(isPhotoView).map((item) => 545 - item.uri 546 - ) ?? []} 547 - photo={photoToView(photo.did, photo)} 548 - />, 549 - ); 550 - }, 551 - ), 552 - route("/actions/photo/:rkey", ["PUT"], async (req, params, ctx) => { 553 - const { did } = ctx.requireAuth(); 554 - const photoRkey = params.rkey; 555 - const formData = await req.formData(); 556 - const alt = formData.get("alt") as string; 557 - const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 558 - const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 559 - if (!photo) return ctx.next(); 560 - await ctx.updateRecord<Photo>("social.grain.photo", photoRkey, { 561 - photo: photo.photo, 562 - aspectRatio: photo.aspectRatio, 563 - alt, 564 - createdAt: photo.createdAt, 565 - }); 566 - return new Response(null, { status: 200 }); 567 - }), 568 - route("/actions/photo/:rkey", ["DELETE"], (_req, params, ctx) => { 569 - const { did } = ctx.requireAuth(); 570 - ctx.deleteRecord( 571 - `at://${did}/social.grain.photo/${params.rkey}`, 572 - ); 573 - return new Response(null, { status: 200 }); 574 - }), 575 - route("/actions/favorite", ["POST"], async (req, _params, ctx) => { 576 - const { did } = ctx.requireAuth(); 577 - const url = new URL(req.url); 578 - const searchParams = new URLSearchParams(url.search); 579 - const galleryUri = searchParams.get("galleryUri"); 580 - const favUri = searchParams.get("favUri") ?? undefined; 581 - if (!galleryUri) return ctx.next(); 582 - 583 - if (favUri) { 584 - await ctx.deleteRecord(favUri); 585 - const favs = getGalleryFavs(galleryUri, ctx); 586 - return ctx.html( 587 - <FavoriteButton 588 - currentUserDid={did} 589 - favs={favs} 590 - galleryUri={galleryUri} 591 - />, 592 - ); 593 - } 594 - 595 - await ctx.createRecord<WithBffMeta<Favorite>>("social.grain.favorite", { 596 - subject: galleryUri, 597 - createdAt: new Date().toISOString(), 598 - }); 599 - 600 - const favs = getGalleryFavs(galleryUri, ctx); 601 - 602 - return ctx.html( 603 - <FavoriteButton 604 - currentUserDid={did} 605 - galleryUri={galleryUri} 606 - favs={favs} 607 - />, 608 - ); 609 - }), 610 - route("/actions/profile/update", ["POST"], async (req, _params, ctx) => { 611 - const { did, handle } = ctx.requireAuth(); 612 - const formData = await req.formData(); 613 - const displayName = formData.get("displayName") as string; 614 - const description = formData.get("description") as string; 615 - const uploadId = formData.get("uploadId") as string; 616 - 617 - const record = ctx.indexService.getRecord<Profile>( 618 - `at://${did}/social.grain.actor.profile/self`, 619 - ); 620 - 621 - if (!record) { 622 - return new Response("Profile record not found", { status: 404 }); 623 - } 624 - 625 - await ctx.updateRecord<Profile>("social.grain.actor.profile", "self", { 626 - displayName, 627 - description, 628 - avatar: photoProcessor.getUploadStatus(uploadId)?.blobRef ?? 629 - record.avatar, 630 - }); 631 - 632 - return ctx.redirect(`/profile/${handle}`); 633 - }), 634 - route( 635 - "/actions/gallery/:rkey/sort", 636 - ["POST"], 637 - async (req, params, ctx) => { 638 - const { did, handle } = ctx.requireAuth(); 639 - const galleryRkey = params.rkey; 640 - const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; 641 - const { 642 - items, 643 - } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 644 - "social.grain.gallery.item", 645 - { 646 - where: [ 647 - { 648 - field: "gallery", 649 - equals: galleryUri, 650 - }, 651 - ], 652 - }, 653 - ); 654 - const itemsMap = new Map<string, WithBffMeta<GalleryItem>>(); 655 - for (const item of items) { 656 - itemsMap.set(item.item, item); 657 - } 658 - const formData = await req.formData(); 659 - const sortedItems = formData.getAll("item") as string[]; 660 - const updates = []; 661 - let position = 0; 662 - for (const sortedItemUri of sortedItems) { 663 - const item = itemsMap.get(sortedItemUri); 664 - if (!item) continue; 665 - updates.push({ 666 - collection: "social.grain.gallery.item", 667 - rkey: new AtUri(item.uri).rkey, 668 - data: { 669 - gallery: item.gallery, 670 - item: item.item, 671 - createdAt: item.createdAt, 672 - position, 673 - }, 674 - }); 675 - position++; 676 - } 677 - await ctx.updateRecords<WithBffMeta<GalleryItem>>(updates); 678 - return ctx.redirect( 679 - galleryLink(handle, new AtUri(galleryUri).rkey), 680 - ); 681 - }, 682 - ), 683 - ...photoUploadRoutes(), 684 - ...avatarUploadRoutes(), 685 - ], 686 - }); 687 - 688 - type State = { 689 - profile?: ProfileView; 690 - scripts?: string[]; 691 - meta?: MetaDescriptor[]; 692 - notifications?: Un$Typed<NotificationView>[]; 693 - }; 694 - 695 - function readFileAsDataURL(file: File): Promise<string> { 696 - return new Promise((resolve, reject) => { 697 - const reader = new FileReader(); 698 - reader.onload = (e) => resolve(e.target?.result as string); 699 - reader.onerror = (e) => reject(e); 700 - reader.readAsDataURL(file); 701 - }); 702 - } 703 - 704 - function createImageFromDataURL(dataURL: string): Promise<Image> { 705 - return new Promise((resolve) => { 706 - const img = new Image(); 707 - img.onload = () => resolve(img); 708 - img.src = dataURL; 709 - }); 710 - } 711 - 712 - async function compressImageForPreview(file: File): Promise<string> { 713 - const maxWidth = 500, 714 - maxHeight = 500, 715 - format = "jpeg"; 716 - 717 - // Create an image from the file 718 - const dataUrl = await readFileAsDataURL(file); 719 - const img = await createImageFromDataURL(dataUrl); 720 - 721 - // Create a canvas with reduced dimensions 722 - const canvas = createCanvas(img.width, img.height); 723 - let width = img.width; 724 - let height = img.height; 725 - 726 - // Calculate new dimensions while maintaining aspect ratio 727 - if (width > height) { 728 - if (width > maxWidth) { 729 - height = Math.round((height * maxWidth) / width); 730 - width = maxWidth; 731 - } 732 - } else { 733 - if (height > maxHeight) { 734 - width = Math.round((width * maxHeight) / height); 735 - height = maxHeight; 736 - } 737 - } 738 - 739 - canvas.width = width; 740 - canvas.height = height; 741 - 742 - // Draw and compress the image 743 - const ctx = canvas.getContext("2d"); 744 - if (!ctx) { 745 - throw new Error("Failed to get canvas context"); 746 - } 747 - ctx.drawImage(img, 0, 0, width, height); 748 - 749 - // Convert to compressed image data URL 750 - return canvas.toDataURL(format); 751 - } 752 - 753 - type TimelineItemType = "gallery" | "favorite"; 754 - 755 - type TimelineItem = { 756 - createdAt: string; 757 - itemType: TimelineItemType; 758 - itemUri: string; 759 - actor: Un$Typed<ProfileView>; 760 - gallery: GalleryView; 761 - }; 762 - 763 - type TimelineOptions = { 764 - actorDid?: string; 765 - }; 766 - 767 - function getFollow(followeeDid: string, followerDid: string, ctx: BffContext) { 768 - const { 769 - items: [follow], 770 - } = ctx.indexService.getRecords<WithBffMeta<BskyFollow>>( 771 - "app.bsky.graph.follow", 772 - { 773 - where: [ 774 - { 775 - field: "did", 776 - equals: followerDid, 777 - }, 778 - { 779 - field: "subject", 780 - equals: followeeDid, 781 - }, 782 - ], 783 - }, 784 - ); 785 - return follow; 786 - } 787 - 788 - function getGalleryItemsAndPhotos( 789 - ctx: BffContext, 790 - galleries: WithBffMeta<Gallery>[], 791 - ): Map<string, WithBffMeta<Photo>[]> { 792 - const galleryUris = galleries.map( 793 - (gallery) => 794 - `at://${gallery.did}/social.grain.gallery/${new AtUri(gallery.uri).rkey}`, 795 - ); 796 - 797 - if (galleryUris.length === 0) return new Map(); 798 - 799 - const { items: galleryItems } = ctx.indexService.getRecords< 800 - WithBffMeta<GalleryItem> 801 - >("social.grain.gallery.item", { 802 - orderBy: [{ field: "position", direction: "asc" }], 803 - where: [{ field: "gallery", in: galleryUris }], 804 - }); 805 - 806 - const photoUris = galleryItems.map((item) => item.item).filter(Boolean); 807 - if (photoUris.length === 0) return new Map(); 808 - 809 - const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>( 810 - "social.grain.photo", 811 - { 812 - where: [{ field: "uri", in: photoUris }], 813 - }, 814 - ); 815 - 816 - const photosMap = new Map<string, WithBffMeta<Photo>>(); 817 - for (const photo of photos) { 818 - photosMap.set(photo.uri, photo); 819 - } 820 - 821 - const galleryPhotosMap = new Map<string, WithBffMeta<Photo>[]>(); 822 - for (const item of galleryItems) { 823 - const galleryUri = item.gallery; 824 - const photo = photosMap.get(item.item); 825 - 826 - if (!galleryPhotosMap.has(galleryUri)) { 827 - galleryPhotosMap.set(galleryUri, []); 828 - } 829 - 830 - if (photo) { 831 - galleryPhotosMap.get(galleryUri)?.push(photo); 832 - } 833 - } 834 - 835 - return galleryPhotosMap; 836 - } 837 - 838 - function processGalleries( 839 - ctx: BffContext, 840 - options?: TimelineOptions, 841 - ): TimelineItem[] { 842 - const items: TimelineItem[] = []; 843 - 844 - const whereClause = options?.actorDid 845 - ? [{ field: "did", equals: options.actorDid }] 846 - : undefined; 847 - 848 - const { items: galleries } = ctx.indexService.getRecords< 849 - WithBffMeta<Gallery> 850 - >("social.grain.gallery", { 851 - orderBy: [{ field: "createdAt", direction: "desc" }], 852 - where: whereClause, 853 - }); 854 - 855 - if (galleries.length === 0) return items; 856 - 857 - // Get photos for all galleries 858 - const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 859 - 860 - for (const gallery of galleries) { 861 - const actor = ctx.indexService.getActor(gallery.did); 862 - if (!actor) continue; 863 - const profile = getActorProfile(actor.did, ctx); 864 - if (!profile) continue; 865 - 866 - const galleryUri = `at://${gallery.did}/social.grain.gallery/${ 867 - new AtUri(gallery.uri).rkey 868 - }`; 869 - const galleryPhotos = galleryPhotosMap.get(galleryUri) || []; 870 - 871 - const galleryView = galleryToView(gallery, profile, galleryPhotos); 872 - items.push({ 873 - itemType: "gallery", 874 - createdAt: gallery.createdAt, 875 - itemUri: galleryView.uri, 876 - actor: galleryView.creator, 877 - gallery: galleryView, 878 - }); 879 - } 880 - 881 - return items; 882 - } 883 - 884 - function processFavs( 885 - ctx: BffContext, 886 - options?: TimelineOptions, 887 - ): TimelineItem[] { 888 - const items: TimelineItem[] = []; 889 - 890 - const whereClause = options?.actorDid 891 - ? [{ field: "did", equals: options.actorDid }] 892 - : undefined; 893 - 894 - const { items: favs } = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 895 - "social.grain.favorite", 896 - { 897 - orderBy: [{ field: "createdAt", direction: "desc" }], 898 - where: whereClause, 899 - }, 900 - ); 901 - 902 - if (favs.length === 0) return items; 903 - 904 - // Collect all gallery references from favorites 905 - const galleryRefs = new Map<string, WithBffMeta<Gallery>>(); 906 - 907 - for (const favorite of favs) { 908 - if (!favorite.subject) continue; 909 - 910 - try { 911 - const atUri = new AtUri(favorite.subject); 912 - const galleryDid = atUri.hostname; 913 - const galleryRkey = atUri.rkey; 914 - const galleryUri = 915 - `at://${galleryDid}/social.grain.gallery/${galleryRkey}`; 916 - 917 - const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 918 - galleryUri, 919 - ); 920 - if (gallery) { 921 - galleryRefs.set(galleryUri, gallery); 922 - } 923 - } catch (e) { 924 - console.error("Error processing favorite:", e); 925 - } 926 - } 927 - 928 - const galleries = Array.from(galleryRefs.values()); 929 - const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 930 - 931 - for (const favorite of favs) { 932 - if (!favorite.subject) continue; 933 - 934 - try { 935 - const atUri = new AtUri(favorite.subject); 936 - const galleryDid = atUri.hostname; 937 - const galleryRkey = atUri.rkey; 938 - const galleryUri = 939 - `at://${galleryDid}/social.grain.gallery/${galleryRkey}`; 940 - 941 - const gallery = galleryRefs.get(galleryUri); 942 - if (!gallery) continue; 943 - 944 - const galleryActor = ctx.indexService.getActor(galleryDid); 945 - if (!galleryActor) continue; 946 - const galleryProfile = getActorProfile(galleryActor.did, ctx); 947 - if (!galleryProfile) continue; 948 - 949 - const favActor = ctx.indexService.getActor(favorite.did); 950 - if (!favActor) continue; 951 - const favProfile = getActorProfile(favActor.did, ctx); 952 - if (!favProfile) continue; 953 - 954 - const galleryPhotos = galleryPhotosMap.get(galleryUri) || []; 955 - const galleryView = galleryToView(gallery, galleryProfile, galleryPhotos); 956 - 957 - items.push({ 958 - itemType: "favorite", 959 - createdAt: favorite.createdAt, 960 - itemUri: favorite.uri, 961 - actor: favProfile, 962 - gallery: galleryView, 963 - }); 964 - } catch (e) { 965 - console.error("Error processing favorite:", e); 966 - continue; 967 - } 968 - } 969 - 970 - return items; 971 - } 972 - 973 - function getTimelineItems( 974 - ctx: BffContext, 975 - options?: TimelineOptions, 976 - ): TimelineItem[] { 977 - const galleryItems = processGalleries(ctx, options); 978 - const favsItems = processFavs(ctx, options); 979 - const timelineItems = [...galleryItems, ...favsItems]; 980 - 981 - return timelineItems.sort( 982 - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 983 - ); 984 - } 985 - 986 - function getTimeline(ctx: BffContext): TimelineItem[] { 987 - return getTimelineItems(ctx); 988 - } 989 - 990 - function getActorTimeline(handleOrDid: string, ctx: BffContext) { 991 - let did: string; 992 - if (handleOrDid.includes("did:")) { 993 - did = handleOrDid; 994 - } else { 995 - const actor = ctx.indexService.getActorByHandle(handleOrDid); 996 - if (!actor) return []; 997 - did = actor.did; 998 - } 999 - return getTimelineItems(ctx, { actorDid: did }); 1000 - } 1001 - 1002 - function getActorPhotos(handleOrDid: string, ctx: BffContext) { 1003 - let did: string; 1004 - if (handleOrDid.includes("did:")) { 1005 - did = handleOrDid; 1006 - } else { 1007 - const actor = ctx.indexService.getActorByHandle(handleOrDid); 1008 - if (!actor) return []; 1009 - did = actor.did; 1010 - } 1011 - const photos = ctx.indexService.getRecords<WithBffMeta<Photo>>( 1012 - "social.grain.photo", 1013 - { 1014 - where: [{ field: "did", equals: did }], 1015 - orderBy: [{ field: "createdAt", direction: "desc" }], 1016 - }, 1017 - ); 1018 - return photos.items.map((photo) => photoToView(photo.did, photo)); 1019 - } 1020 - 1021 - function getActorGalleries(handleOrDid: string, ctx: BffContext) { 1022 - let did: string; 1023 - if (handleOrDid.includes("did:")) { 1024 - did = handleOrDid; 1025 - } else { 1026 - const actor = ctx.indexService.getActorByHandle(handleOrDid); 1027 - if (!actor) return []; 1028 - did = actor.did; 1029 - } 1030 - const { items: galleries } = ctx.indexService.getRecords< 1031 - WithBffMeta<Gallery> 1032 - >("social.grain.gallery", { 1033 - where: [{ field: "did", equals: did }], 1034 - orderBy: [{ field: "createdAt", direction: "desc" }], 1035 - }); 1036 - const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 1037 - const creator = getActorProfile(did, ctx); 1038 - if (!creator) return []; 1039 - return galleries.map((gallery) => 1040 - galleryToView(gallery, creator, galleryPhotosMap.get(gallery.uri) ?? []) 1041 - ); 1042 - } 1043 - 1044 - function getNotifications( 1045 - currentUser: ActorTable, 1046 - ctx: BffContext, 1047 - ) { 1048 - const { lastSeenNotifs } = currentUser; 1049 - const notifications = ctx.getNotifications<NotificationRecords>(); 1050 - return notifications.map((notification) => { 1051 - const actor = ctx.indexService.getActor(notification.did); 1052 - const authorProfile = getActorProfile(notification.did, ctx); 1053 - if (!actor || !authorProfile) return null; 1054 - return notificationToView( 1055 - notification, 1056 - authorProfile, 1057 - lastSeenNotifs, 1058 - ); 1059 - }).filter((view): view is Un$Typed<NotificationView> => Boolean(view)); 1060 - } 1061 - 1062 - function getGallery(handleOrDid: string, rkey: string, ctx: BffContext) { 1063 - let did: string; 1064 - if (handleOrDid.includes("did:")) { 1065 - did = handleOrDid; 1066 - } else { 1067 - const actor = ctx.indexService.getActorByHandle(handleOrDid); 1068 - if (!actor) return null; 1069 - did = actor.did; 1070 - } 1071 - const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 1072 - `at://${did}/social.grain.gallery/${rkey}`, 1073 - ); 1074 - if (!gallery) return null; 1075 - const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]); 1076 - const profile = getActorProfile(did, ctx); 1077 - if (!profile) return null; 1078 - return galleryToView( 1079 - gallery, 1080 - profile, 1081 - galleryPhotosMap.get(gallery.uri) ?? [], 1082 - ); 1083 - } 1084 - 1085 - async function deleteGallery(uri: string, ctx: BffContext) { 1086 - await ctx.deleteRecord(uri); 1087 - const { items: galleryItems } = ctx.indexService.getRecords< 1088 - WithBffMeta<GalleryItem> 1089 - >("social.grain.gallery.item", { 1090 - where: [{ field: "gallery", equals: uri }], 1091 - }); 1092 - for (const item of galleryItems) { 1093 - await ctx.deleteRecord(item.uri); 1094 - } 1095 - const { items: favs } = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 1096 - "social.grain.favorite", 1097 - { 1098 - where: [{ field: "subject", equals: uri }], 1099 - }, 1100 - ); 1101 - for (const fav of favs) { 1102 - await ctx.deleteRecord(fav.uri); 1103 - } 1104 - } 1105 - 1106 - function getGalleryFavs(galleryUri: string, ctx: BffContext) { 1107 - const atUri = new AtUri(galleryUri); 1108 - const results = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 1109 - "social.grain.favorite", 1110 - { 1111 - where: [ 1112 - { 1113 - field: "subject", 1114 - equals: `at://${atUri.hostname}/social.grain.gallery/${atUri.rkey}`, 1115 - }, 1116 - ], 1117 - }, 1118 - ); 1119 - return results.items; 1120 - } 1121 - 1122 - function getPageMeta(pageUrl: string): MetaDescriptor[] { 1123 - return [ 1124 - { 1125 - tagName: "link", 1126 - property: "canonical", 1127 - href: `${PUBLIC_URL}${pageUrl}`, 1128 - }, 1129 - { property: "og:site_name", content: "Grain Social" }, 1130 - ]; 1131 - } 1132 - 1133 - function getGalleryMeta(gallery: GalleryView): MetaDescriptor[] { 1134 - return [ 1135 - // { property: "og:type", content: "website" }, 1136 - { 1137 - property: "og:url", 1138 - content: `${PUBLIC_URL}${ 1139 - galleryLink( 1140 - gallery.creator.handle, 1141 - new AtUri(gallery.uri).rkey, 1142 - ) 1143 - }`, 1144 - }, 1145 - { property: "og:title", content: (gallery.record as Gallery).title }, 1146 - { 1147 - property: "og:description", 1148 - content: (gallery.record as Gallery).description, 1149 - }, 1150 - { 1151 - property: "og:image", 1152 - content: gallery?.items?.filter(isPhotoView)?.[0]?.thumb, 1153 - }, 1154 - ]; 1155 - } 1156 - 1157 - function Root(props: Readonly<RootProps<State>>) { 1158 - const profile = props.ctx.state.profile; 1159 - const scripts = props.ctx.state.scripts; 1160 - const hasNotifications = 1161 - props.ctx.state.notifications?.find((n) => n.isRead === false) !== 1162 - undefined; 1163 - return ( 1164 - <html lang="en" class="w-full h-full"> 1165 - <head> 1166 - <meta charset="UTF-8" /> 1167 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 1168 - <Meta meta={props.ctx.state.meta} /> 1169 - {GOATCOUNTER_URL 1170 - ? ( 1171 - <script 1172 - data-goatcounter={GOATCOUNTER_URL} 1173 - async 1174 - src="//gc.zgo.at/count.js" 1175 - /> 1176 - ) 1177 - : null} 1178 - <script src="https://unpkg.com/htmx.org@1.9.10" /> 1179 - <script src="https://unpkg.com/hyperscript.org@0.9.14" /> 1180 - <script src="https://unpkg.com/sortablejs@1.15.6" /> 1181 - <style dangerouslySetInnerHTML={{ __html: CSS }} /> 1182 - <link 1183 - rel="stylesheet" 1184 - href={`/static/styles.css?${staticFilesHash.get("styles.css")}`} 1185 - /> 1186 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 1187 - <link 1188 - rel="preconnect" 1189 - href="https://fonts.gstatic.com" 1190 - crossOrigin="anonymous" 1191 - /> 1192 - <link 1193 - href="https://fonts.googleapis.com/css2?family=Jersey+20&display=swap" 1194 - rel="stylesheet" 1195 - /> 1196 - <link 1197 - rel="stylesheet" 1198 - href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 1199 - preload 1200 - /> 1201 - {scripts?.map((file) => ( 1202 - <script 1203 - key={file} 1204 - src={`/static/${file}?${staticFilesHash.get(file)}`} 1205 - /> 1206 - ))} 1207 - </head> 1208 - <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 1209 - <Layout id="layout" class="border-zinc-200 dark:border-zinc-800"> 1210 - <Layout.Nav 1211 - heading={ 1212 - <h1 class="font-['Jersey_20'] text-4xl text-zinc-900 dark:text-white"> 1213 - grain 1214 - <sub class="bottom-[0.75rem] text-[1rem]">beta</sub> 1215 - </h1> 1216 - } 1217 - profile={profile} 1218 - hasNotifications={hasNotifications} 1219 - class="border-zinc-200 dark:border-zinc-800" 1220 - /> 1221 - <Layout.Content>{props.children}</Layout.Content> 1222 - </Layout> 1223 - </body> 1224 - </html> 1225 - ); 1226 - } 1227 - 1228 - function Header({ 1229 - children, 1230 - class: classProp, 1231 - ...props 1232 - }: Readonly< 1233 - JSX.HTMLAttributes<HTMLHeadingElement> & { children: ComponentChildren } 1234 - >) { 1235 - return ( 1236 - <h1 class={cn("text-xl font-semibold", classProp)} {...props}> 1237 - {children} 1238 - </h1> 1239 - ); 1240 - } 1241 - 1242 - function AvatarButton({ 1243 - profile, 1244 - }: Readonly<{ profile: Un$Typed<ProfileView> }>) { 1245 - return ( 1246 - <button 1247 - type="button" 1248 - class="cursor-pointer" 1249 - hx-get={`/dialogs/avatar/${profile.handle}`} 1250 - hx-trigger="click" 1251 - hx-target="body" 1252 - hx-swap="afterbegin" 1253 - > 1254 - <img 1255 - src={profile.avatar} 1256 - alt={profile.handle} 1257 - class="rounded-full object-cover size-16" 1258 - /> 1259 - </button> 1260 - ); 1261 - } 1262 - 1263 - function AvatarDialog({ 1264 - profile, 1265 - }: Readonly<{ profile: Un$Typed<ProfileView> }>) { 1266 - return ( 1267 - <Dialog> 1268 - <Dialog.X /> 1269 - <div 1270 - class="w-[400px] h-[400px] flex flex-col p-4 z-10" 1271 - _={Dialog._closeOnClick} 1272 - > 1273 - <ActorAvatar class="w-full h-full" profile={profile} /> 1274 - </div> 1275 - </Dialog> 1276 - ); 1277 - } 1278 - 1279 - function ActorAvatar({ 1280 - profile, 1281 - class: classProp, 1282 - }: Readonly<{ profile: Un$Typed<ProfileView>; class?: string }>) { 1283 - return ( 1284 - <img 1285 - src={profile.avatar} 1286 - alt={profile.handle} 1287 - title={profile.handle} 1288 - class={cn("rounded-full object-cover", classProp)} 1289 - /> 1290 - ); 1291 - } 1292 - 1293 - function ActorInfo({ profile }: Readonly<{ profile: Un$Typed<ProfileView> }>) { 1294 - return ( 1295 - <div class="flex items-center gap-2 min-w-0 flex-1"> 1296 - <ActorAvatar profile={profile} class="size-7 shrink-0" /> 1297 - <a 1298 - href={profileLink(profile.handle)} 1299 - class="hover:underline text-zinc-600 dark:text-zinc-500 truncate max-w-[300px] sm:max-w-[400px]" 1300 - > 1301 - <span class="text-zinc-950 dark:text-zinc-50 font-semibold text-"> 1302 - {profile.displayName || profile.handle} 1303 - </span>{" "} 1304 - <span class="truncate">@{profile.handle}</span> 1305 - </a> 1306 - </div> 1307 - ); 1308 - } 1309 - 1310 - function Timeline({ items }: Readonly<{ items: TimelineItem[] }>) { 1311 - return ( 1312 - <div class="px-4 mb-4"> 1313 - <div class="my-4"> 1314 - <Header>Timeline</Header> 1315 - </div> 1316 - <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 1317 - {items.map((item) => <TimelineItem item={item} key={item.itemUri} />)} 1318 - </ul> 1319 - </div> 1320 - ); 1321 - } 1322 - 1323 - function TimelineItem({ item }: Readonly<{ item: TimelineItem }>) { 1324 - return ( 1325 - <li> 1326 - <div class="w-fit flex flex-col gap-4 pb-4"> 1327 - <div class="flex items-center justify-between gap-2 w-full"> 1328 - <ActorInfo profile={item.actor} /> 1329 - <span class="shrink-0"> 1330 - {formatRelativeTime(new Date(item.createdAt))} 1331 - </span> 1332 - </div> 1333 - {item.gallery.items?.filter(isPhotoView).length 1334 - ? ( 1335 - <GalleryPreviewLink 1336 - gallery={item.gallery} 1337 - /> 1338 - ) 1339 - : null} 1340 - <p> 1341 - {item.itemType === "favorite" ? "Favorited" : "Created"}{" "} 1342 - <a 1343 - href={galleryLink( 1344 - item.gallery.creator.handle, 1345 - new AtUri(item.gallery.uri).rkey, 1346 - )} 1347 - class="font-semibold hover:underline" 1348 - > 1349 - {(item.gallery.record as Gallery).title} 1350 - </a> 1351 - </p> 1352 - </div> 1353 - </li> 1354 - ); 1355 - } 1356 - 1357 - function GalleryPreviewLink({ 1358 - gallery, 1359 - size = "default", 1360 - }: Readonly<{ gallery: Un$Typed<GalleryView>; size?: "small" | "default" }>) { 1361 - const gap = size === "small" ? "gap-1" : "gap-2"; 1362 - return ( 1363 - <a 1364 - href={galleryLink( 1365 - gallery.creator.handle, 1366 - new AtUri(gallery.uri).rkey, 1367 - )} 1368 - class={cn("flex w-full max-w-md aspect-[3/2] overflow-hidden", gap)} 1369 - > 1370 - <div class="w-2/3 h-full"> 1371 - <img 1372 - src={gallery.items?.filter(isPhotoView)[0].thumb} 1373 - alt={gallery.items?.filter(isPhotoView)[0].alt} 1374 - class="w-full h-full object-cover" 1375 - /> 1376 - </div> 1377 - <div class={cn("w-1/3 flex flex-col h-full", gap)}> 1378 - <div class="h-1/2"> 1379 - {gallery.items?.filter(isPhotoView)?.[1] 1380 - ? ( 1381 - <img 1382 - src={gallery.items?.filter(isPhotoView)?.[1] 1383 - ?.thumb} 1384 - alt={gallery.items?.filter(isPhotoView)?.[1]?.alt} 1385 - class="w-full h-full object-cover" 1386 - /> 1387 - ) 1388 - : <div className="w-full h-full bg-zinc-200 dark:bg-zinc-900" />} 1389 - </div> 1390 - <div class="h-1/2"> 1391 - {gallery.items?.filter(isPhotoView)?.[2] 1392 - ? ( 1393 - <img 1394 - src={gallery.items?.filter(isPhotoView)?.[2] 1395 - ?.thumb} 1396 - alt={gallery.items?.filter(isPhotoView)?.[2]?.alt} 1397 - class="w-full h-full object-cover" 1398 - /> 1399 - ) 1400 - : <div className="w-full h-full bg-zinc-200 dark:bg-zinc-900" />} 1401 - </div> 1402 - </div> 1403 - </a> 1404 - ); 1405 - } 1406 - 1407 - function FollowButton({ 1408 - followeeDid, 1409 - followUri, 1410 - }: Readonly<{ followeeDid: string; load?: boolean; followUri?: string }>) { 1411 - const isFollowing = followUri; 1412 - return ( 1413 - <Button 1414 - variant="primary" 1415 - class={cn( 1416 - "w-full sm:w-fit", 1417 - isFollowing && 1418 - "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 1419 - )} 1420 - {...(isFollowing 1421 - ? { 1422 - children: "Following", 1423 - "hx-delete": `/actions/follow/${followeeDid}/${ 1424 - new AtUri(followUri).rkey 1425 - }`, 1426 - } 1427 - : { 1428 - children: ( 1429 - <> 1430 - <i class="fa-solid fa-plus mr-2" /> 1431 - Follow 1432 - </> 1433 - ), 1434 - "hx-post": `/actions/follow/${followeeDid}`, 1435 - })} 1436 - hx-trigger="click" 1437 - hx-target="this" 1438 - hx-swap="outerHTML" 1439 - /> 1440 - ); 1441 - } 1442 - 1443 - function formatRelativeTime(date: Date) { 1444 - const now = new Date(); 1445 - const weeks = differenceInWeeks(now, date); 1446 - if (weeks > 0) return `${weeks}w`; 1447 - 1448 - const days = differenceInDays(now, date); 1449 - if (days > 0) return `${days}d`; 1450 - 1451 - const hours = differenceInHours(now, date); 1452 - if (hours > 0) return `${hours}h`; 1453 - 1454 - const minutes = differenceInMinutes(now, date); 1455 - return `${Math.max(1, minutes)}m`; 1456 - } 1457 - 1458 - function NotificationsPage( 1459 - { notifications }: Readonly<{ notifications: Un$Typed<NotificationView>[] }>, 1460 - ) { 1461 - return ( 1462 - <div class="px-4 mb-4"> 1463 - <div hx-post="/actions/update-seen" hx-trigger="load delay:1s" /> 1464 - <div class="my-4"> 1465 - <Header>Notifications</Header> 1466 - </div> 1467 - <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y"> 1468 - {notifications.length 1469 - ? ( 1470 - notifications.map((notification) => ( 1471 - <li 1472 - key={notification.uri} 1473 - class="flex flex-col gap-4 pb-4" 1474 - > 1475 - <div class="flex flex-wrap items-center gap-2"> 1476 - <a 1477 - href={profileLink(notification.author.handle)} 1478 - class="flex items-center gap-2 hover:underline" 1479 - > 1480 - <ActorAvatar 1481 - profile={notification.author} 1482 - class="h-8 w-8" 1483 - /> 1484 - <span class="font-semibold break-words"> 1485 - {notification.author.displayName ?? 1486 - notification.author.handle} 1487 - </span> 1488 - </a> 1489 - <span class="break-words"> 1490 - favorited your gallery · {formatRelativeTime( 1491 - new Date((notification.record as Favorite).createdAt), 1492 - )} 1493 - </span> 1494 - </div> 1495 - <div 1496 - hx-get={`/embed/profile/${ 1497 - new AtUri(notification.reasonSubject ?? "").hostname 1498 - }/gallery/${ 1499 - new AtUri(notification.reasonSubject ?? "").rkey 1500 - }`} 1501 - hx-trigger="load" 1502 - hx-target="this" 1503 - hx-swap="innerHTML" 1504 - class="w-[200px]" 1505 - /> 1506 - </li> 1507 - )) 1508 - ) 1509 - : <li>No notifications yet.</li>} 1510 - </ul> 1511 - </div> 1512 - ); 1513 - } 1514 - 1515 - function ProfilePage({ 1516 - followUri, 1517 - loggedInUserDid, 1518 - timelineItems, 1519 - profile, 1520 - selectedTab, 1521 - galleries, 1522 - }: Readonly<{ 1523 - followUri?: string; 1524 - loggedInUserDid?: string; 1525 - timelineItems: TimelineItem[]; 1526 - profile: Un$Typed<ProfileView>; 1527 - selectedTab?: string; 1528 - galleries?: GalleryView[]; 1529 - }>) { 1530 - const isCreator = loggedInUserDid === profile.did; 1531 - const displayName = profile.displayName || profile.handle; 1532 - return ( 1533 - <div class="px-4 mb-4" id="profile-page"> 1534 - <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> 1535 - <div class="flex flex-col mb-4"> 1536 - <AvatarButton profile={profile} /> 1537 - <p class="text-2xl font-bold">{displayName}</p> 1538 - <p class="text-zinc-600 dark:text-zinc-500">@{profile.handle}</p> 1539 - {profile.description 1540 - ? <p class="mt-2">{profile.description}</p> 1541 - : null} 1542 - </div> 1543 - {!isCreator && loggedInUserDid 1544 - ? ( 1545 - <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1546 - <FollowButton followeeDid={profile.did} followUri={followUri} /> 1547 - </div> 1548 - ) 1549 - : null} 1550 - {isCreator 1551 - ? ( 1552 - <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1553 - <Button variant="primary" class="w-full sm:w-fit" asChild> 1554 - <a href="/upload"> 1555 - <i class="fa-solid fa-upload mr-2" /> 1556 - Upload 1557 - </a> 1558 - </Button> 1559 - <Button 1560 - variant="primary" 1561 - type="button" 1562 - hx-get="/dialogs/profile" 1563 - hx-target="#layout" 1564 - hx-swap="afterbegin" 1565 - class="w-full sm:w-fit" 1566 - > 1567 - Edit Profile 1568 - </Button> 1569 - <Button 1570 - variant="primary" 1571 - type="button" 1572 - class="w-full sm:w-fit" 1573 - hx-get="/dialogs/gallery/new" 1574 - hx-target="#layout" 1575 - hx-swap="afterbegin" 1576 - > 1577 - Create Gallery 1578 - </Button> 1579 - </div> 1580 - ) 1581 - : null} 1582 - </div> 1583 - <div class="my-4 space-x-2 w-full flex sm:w-fit" role="tablist"> 1584 - <button 1585 - type="button" 1586 - hx-get={profileLink(profile.handle)} 1587 - hx-target="body" 1588 - hx-swap="outerHTML" 1589 - class={cn( 1590 - "flex-1 py-2 px-4 cursor-pointer font-semibold", 1591 - !selectedTab && "bg-zinc-100 dark:bg-zinc-800 font-semibold", 1592 - )} 1593 - role="tab" 1594 - aria-selected="true" 1595 - aria-controls="tab-content" 1596 - > 1597 - Activity 1598 - </button> 1599 - <button 1600 - type="button" 1601 - hx-get={profileLink(profile.handle) + "?tab=galleries"} 1602 - hx-target="#profile-page" 1603 - hx-swap="outerHTML" 1604 - class={cn( 1605 - "flex-1 py-2 px-4 cursor-pointer font-semibold", 1606 - selectedTab === "galleries" && "bg-zinc-100 dark:bg-zinc-800", 1607 - )} 1608 - role="tab" 1609 - aria-selected="false" 1610 - aria-controls="tab-content" 1611 - > 1612 - Galleries 1613 - </button> 1614 - </div> 1615 - <div id="tab-content" role="tabpanel"> 1616 - {!selectedTab 1617 - ? ( 1618 - <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 1619 - {timelineItems.length 1620 - ? ( 1621 - timelineItems.map((item) => ( 1622 - <TimelineItem item={item} key={item.itemUri} /> 1623 - )) 1624 - ) 1625 - : <li>No activity yet.</li>} 1626 - </ul> 1627 - ) 1628 - : null} 1629 - {selectedTab === "galleries" 1630 - ? ( 1631 - <div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4"> 1632 - {galleries?.length 1633 - ? ( 1634 - galleries.map((gallery) => ( 1635 - <a 1636 - href={galleryLink( 1637 - gallery.creator.handle, 1638 - new AtUri(gallery.uri).rkey, 1639 - )} 1640 - class="cursor-pointer relative aspect-square" 1641 - > 1642 - {gallery.items?.length 1643 - ? ( 1644 - <img 1645 - src={gallery.items?.filter(isPhotoView)?.[0] 1646 - ?.fullsize} 1647 - alt={gallery.items?.filter(isPhotoView)?.[0]?.alt} 1648 - class="w-full h-full object-cover" 1649 - /> 1650 - ) 1651 - : ( 1652 - <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" /> 1653 - )} 1654 - <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2"> 1655 - {(gallery.record as Gallery).title} 1656 - </div> 1657 - </a> 1658 - )) 1659 - ) 1660 - : <p>No galleries yet.</p>} 1661 - </div> 1662 - ) 1663 - : null} 1664 - </div> 1665 - </div> 1666 - ); 1667 - } 1668 - 1669 - function UploadPage({ 1670 - handle, 1671 - photos, 1672 - returnTo, 1673 - }: Readonly<{ handle: string; photos: PhotoView[]; returnTo?: string }>) { 1674 - return ( 1675 - <div class="flex flex-col px-4 pt-4 mb-4 space-y-4"> 1676 - <div class="flex"> 1677 - <div class="flex-1"> 1678 - {returnTo 1679 - ? ( 1680 - <a href={returnTo} class="hover:underline"> 1681 - <i class="fa-solid fa-arrow-left mr-2" /> 1682 - Back to gallery 1683 - </a> 1684 - ) 1685 - : ( 1686 - <a href={profileLink(handle)} class="hover:underline"> 1687 - <i class="fa-solid fa-arrow-left mr-2" /> 1688 - Back to profile 1689 - </a> 1690 - )} 1691 - </div> 1692 - </div> 1693 - <Button variant="primary" class="mb-4 w-full sm:w-fit" asChild> 1694 - <label> 1695 - <i class="fa fa-plus"></i> Add photos 1696 - <input 1697 - class="hidden" 1698 - type="file" 1699 - multiple 1700 - accept="image/*" 1701 - _="on change call uploadPhotos(me)" 1702 - /> 1703 - </label> 1704 - </Button> 1705 - <div 1706 - id="image-preview" 1707 - class="w-full h-full grid grid-cols-2 sm:grid-cols-5 gap-2" 1708 - > 1709 - {photos.map((photo) => ( 1710 - <PhotoPreview key={photo.cid} src={photo.thumb} uri={photo.uri} /> 1711 - ))} 1712 - </div> 1713 - </div> 1714 - ); 1715 - } 1716 - 1717 - function ProfileDialog({ 1718 - profile, 1719 - }: Readonly<{ 1720 - profile: ProfileView; 1721 - }>) { 1722 - return ( 1723 - <Dialog> 1724 - <Dialog.Content class="dark:bg-zinc-950 relative"> 1725 - <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 1726 - <Dialog.Title>Edit my profile</Dialog.Title> 1727 - <div> 1728 - <AvatarForm src={profile.avatar} alt={profile.handle} /> 1729 - </div> 1730 - <form 1731 - hx-post="/actions/profile/update" 1732 - hx-swap="none" 1733 - _="on htmx:afterOnLoad trigger closeModal" 1734 - > 1735 - <div id="image-input" /> 1736 - <div class="mb-4 relative"> 1737 - <label htmlFor="displayName">Display Name</label> 1738 - <Input 1739 - type="text" 1740 - required 1741 - id="displayName" 1742 - name="displayName" 1743 - class="dark:bg-zinc-800 dark:text-white" 1744 - value={profile.displayName} 1745 - autoFocus 1746 - /> 1747 - </div> 1748 - <div class="mb-4 relative"> 1749 - <label htmlFor="description">Description</label> 1750 - <Textarea 1751 - id="description" 1752 - name="description" 1753 - rows={4} 1754 - class="dark:bg-zinc-800 dark:text-white" 1755 - > 1756 - {profile.description} 1757 - </Textarea> 1758 - </div> 1759 - <Button type="submit" variant="primary" class="w-full"> 1760 - Update 1761 - </Button> 1762 - <Button 1763 - variant="secondary" 1764 - type="button" 1765 - class="w-full" 1766 - _={Dialog._closeOnClick} 1767 - > 1768 - Cancel 1769 - </Button> 1770 - </form> 1771 - </Dialog.Content> 1772 - </Dialog> 1773 - ); 1774 - } 1775 - 1776 - function AvatarForm({ src, alt }: Readonly<{ src?: string; alt?: string }>) { 1777 - return ( 1778 - <form 1779 - id="avatar-file-form" 1780 - hx-post="/actions/avatar/upload-start" 1781 - hx-target="#image-preview" 1782 - hx-swap="innerHTML" 1783 - hx-encoding="multipart/form-data" 1784 - hx-trigger="change from:#file" 1785 - > 1786 - <label htmlFor="file"> 1787 - <span class="sr-only">Upload avatar</span> 1788 - <div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer"> 1789 - <div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 1790 - <i class="fa-solid fa-camera text-white text-xs"></i> 1791 - </div> 1792 - <div id="image-preview" class="w-full h-full"> 1793 - {src 1794 - ? ( 1795 - <img 1796 - src={src} 1797 - alt={alt} 1798 - className="rounded-full w-full h-full object-cover" 1799 - /> 1800 - ) 1801 - : null} 1802 - </div> 1803 - </div> 1804 - <input 1805 - class="hidden" 1806 - type="file" 1807 - id="file" 1808 - name="file" 1809 - accept="image/*" 1810 - /> 1811 - </label> 1812 - </form> 1813 - ); 1814 - } 1815 - 1816 - function GalleryPage({ 1817 - gallery, 1818 - favs = [], 1819 - currentUserDid, 1820 - }: Readonly<{ 1821 - gallery: GalleryView; 1822 - favs: WithBffMeta<Favorite>[]; 1823 - currentUserDid?: string; 1824 - }>) { 1825 - const isCreator = currentUserDid === gallery.creator.did; 1826 - const isLoggedIn = !!currentUserDid; 1827 - const description = (gallery.record as Gallery).description; 1828 - return ( 1829 - <div class="px-4"> 1830 - <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4 mb-2"> 1831 - <div class="flex flex-col space-y-2 mb-4"> 1832 - <h1 class="font-bold text-2xl"> 1833 - {(gallery.record as Gallery).title} 1834 - </h1> 1835 - <ActorInfo profile={gallery.creator} /> 1836 - {description ? <p>{description}</p> : null} 1837 - </div> 1838 - {isLoggedIn && isCreator 1839 - ? ( 1840 - <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1841 - <Button 1842 - variant="primary" 1843 - class="self-start w-full sm:w-fit" 1844 - hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`} 1845 - hx-target="#layout" 1846 - hx-swap="afterbegin" 1847 - > 1848 - Edit 1849 - </Button> 1850 - <Button 1851 - hx-get={`/dialogs/photo-select/${new AtUri(gallery.uri).rkey}`} 1852 - hx-target="#layout" 1853 - hx-swap="afterbegin" 1854 - variant="primary" 1855 - class="self-start w-full sm:w-fit" 1856 - > 1857 - Add photos 1858 - </Button> 1859 - <Button 1860 - variant="primary" 1861 - class="self-start w-full sm:w-fit" 1862 - hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/sort`} 1863 - hx-target="#layout" 1864 - hx-swap="afterbegin" 1865 - > 1866 - Sort order 1867 - </Button> 1868 - <ShareGalleryButton gallery={gallery} /> 1869 - </div> 1870 - ) 1871 - : null} 1872 - {!isCreator 1873 - ? ( 1874 - <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1875 - <ShareGalleryButton gallery={gallery} /> 1876 - <FavoriteButton 1877 - currentUserDid={currentUserDid} 1878 - favs={favs} 1879 - galleryUri={gallery.uri} 1880 - /> 1881 - </div> 1882 - ) 1883 - : null} 1884 - </div> 1885 - <div class="flex justify-end mb-2"> 1886 - <Button 1887 - id="justified-button" 1888 - title="Justified layout" 1889 - variant="primary" 1890 - class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 1891 - _="on click call toggleLayout('justified') 1892 - set @data-selected to 'true' 1893 - set #masonry-button's @data-selected to 'false'" 1894 - > 1895 - <svg 1896 - width="24" 1897 - height="24" 1898 - viewBox="0 0 24 24" 1899 - xmlns="http://www.w3.org/2000/svg" 1900 - > 1901 - <rect x="2" y="2" width="8" height="6" fill="currentColor" rx="1" /> 1902 - <rect 1903 - x="12" 1904 - y="2" 1905 - width="10" 1906 - height="6" 1907 - fill="currentColor" 1908 - rx="1" 1909 - /> 1910 - <rect 1911 - x="2" 1912 - y="10" 1913 - width="6" 1914 - height="6" 1915 - fill="currentColor" 1916 - rx="1" 1917 - /> 1918 - <rect 1919 - x="10" 1920 - y="10" 1921 - width="12" 1922 - height="6" 1923 - fill="currentColor" 1924 - rx="1" 1925 - /> 1926 - <rect 1927 - x="2" 1928 - y="18" 1929 - width="20" 1930 - height="4" 1931 - fill="currentColor" 1932 - rx="1" 1933 - /> 1934 - </svg> 1935 - </Button> 1936 - <Button 1937 - id="masonry-button" 1938 - title="Masonry layout" 1939 - variant="primary" 1940 - data-selected="false" 1941 - class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 1942 - _="on click call toggleLayout('masonry') 1943 - set @data-selected to 'true' 1944 - set #justified-button's @data-selected to 'false'" 1945 - > 1946 - <svg 1947 - width="24" 1948 - height="24" 1949 - viewBox="0 0 24 24" 1950 - xmlns="http://www.w3.org/2000/svg" 1951 - > 1952 - <rect x="2" y="2" width="8" height="8" fill="currentColor" rx="1" /> 1953 - <rect 1954 - x="12" 1955 - y="2" 1956 - width="8" 1957 - height="4" 1958 - fill="currentColor" 1959 - rx="1" 1960 - /> 1961 - <rect 1962 - x="12" 1963 - y="8" 1964 - width="8" 1965 - height="6" 1966 - fill="currentColor" 1967 - rx="1" 1968 - /> 1969 - <rect 1970 - x="2" 1971 - y="12" 1972 - width="8" 1973 - height="8" 1974 - fill="currentColor" 1975 - rx="1" 1976 - /> 1977 - <rect 1978 - x="12" 1979 - y="16" 1980 - width="8" 1981 - height="4" 1982 - fill="currentColor" 1983 - rx="1" 1984 - /> 1985 - </svg> 1986 - </Button> 1987 - </div> 1988 - <div 1989 - id="masonry-container" 1990 - class="h-0 overflow-hidden relative mx-auto w-full" 1991 - _="on load or htmx:afterSettle call computeLayout()" 1992 - > 1993 - {gallery.items?.filter(isPhotoView)?.length 1994 - ? gallery?.items 1995 - ?.filter(isPhotoView) 1996 - ?.map((photo) => ( 1997 - <PhotoButton 1998 - key={photo.cid} 1999 - photo={photo} 2000 - gallery={gallery} 2001 - /> 2002 - )) 2003 - : null} 2004 - </div> 2005 - </div> 2006 - ); 2007 - } 2008 - 2009 - function PhotoButton({ 2010 - photo, 2011 - gallery, 2012 - }: Readonly<{ 2013 - photo: PhotoView; 2014 - gallery: GalleryView; 2015 - }>) { 2016 - return ( 2017 - <button 2018 - id={`photo-${new AtUri(photo.uri).rkey}`} 2019 - type="button" 2020 - hx-get={photoDialogLink(gallery, photo)} 2021 - hx-trigger="click" 2022 - hx-target="#layout" 2023 - hx-swap="afterbegin" 2024 - class="masonry-tile absolute cursor-pointer" 2025 - data-width={photo.aspectRatio?.width} 2026 - data-height={photo.aspectRatio?.height} 2027 - > 2028 - <img 2029 - src={photo.fullsize} 2030 - alt={photo.alt} 2031 - class="w-full h-full object-cover" 2032 - /> 2033 - {photo.alt 2034 - ? ( 2035 - <div class="absolute bg-zinc-950 dark:bg-zinc-900 bottom-1 right-1 sm:bottom-1 sm:right-1 text-xs text-white font-semibold py-[1px] px-[3px]"> 2036 - ALT 2037 - </div> 2038 - ) 2039 - : null} 2040 - </button> 2041 - ); 2042 - } 2043 - 2044 - function GallerySortDialog({ gallery }: Readonly<{ gallery: GalleryView }>) { 2045 - return ( 2046 - <Dialog> 2047 - <Dialog.Content class="dark:bg-zinc-950 relative"> 2048 - <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 2049 - <Dialog.Title>Sort gallery</Dialog.Title> 2050 - <p class="my-2 text-center">Drag photos to rearrange</p> 2051 - <form 2052 - hx-post={`/actions/gallery/${new AtUri(gallery.uri).rkey}/sort`} 2053 - hx-trigger="submit" 2054 - hx-swap="none" 2055 - > 2056 - <div class="sortable grid grid-cols-3 sm:grid-cols-5 gap-2 mt-2"> 2057 - {gallery?.items?.filter(isPhotoView).map((item) => ( 2058 - <div 2059 - key={item.cid} 2060 - class="relative aspect-square cursor-grab" 2061 - > 2062 - <input type="hidden" name="item" value={item.uri} /> 2063 - <img 2064 - src={item.fullsize} 2065 - alt={item.alt} 2066 - class="w-full h-full absolute object-cover" 2067 - /> 2068 - </div> 2069 - ))} 2070 - </div> 2071 - <div class="flex flex-col gap-2 mt-2"> 2072 - <Button 2073 - variant="primary" 2074 - type="submit" 2075 - class="w-full" 2076 - > 2077 - Save 2078 - </Button> 2079 - <Button 2080 - variant="secondary" 2081 - type="button" 2082 - class="w-full" 2083 - _={Dialog._closeOnClick} 2084 - > 2085 - Cancel 2086 - </Button> 2087 - </div> 2088 - </form> 2089 - </Dialog.Content> 2090 - </Dialog> 2091 - ); 2092 - } 2093 - 2094 - function ShareGalleryButton({ gallery }: Readonly<{ gallery: GalleryView }>) { 2095 - const intentLink = `https://bsky.app/intent/compose?text=${ 2096 - encodeURIComponent( 2097 - "Check out this gallery on @grain.social \n" + 2098 - publicGalleryLink(gallery.creator.handle, gallery.uri), 2099 - ) 2100 - }`; 2101 - return ( 2102 - <Button 2103 - variant="primary" 2104 - asChild 2105 - > 2106 - <a href={intentLink} target="_blank" rel="noopener noreferrer"> 2107 - <i class="fa-solid fa-arrow-up-from-bracket mr-2" /> 2108 - Share to Bluesky 2109 - </a> 2110 - </Button> 2111 - ); 2112 - } 2113 - 2114 - // function ShareGalleryButton({ gallery }: Readonly<{ gallery: GalleryView }>) { 2115 - // return ( 2116 - // <> 2117 - // <input 2118 - // type="hidden" 2119 - // id="copy-text" 2120 - // value={publicGalleryLink(gallery.creator.handle, gallery.uri)} 2121 - // /> 2122 - // <Button 2123 - // variant="primary" 2124 - // _={`on click 2125 - // set copyText to #copy-text.value 2126 - // writeText(copyText) on navigator.clipboard 2127 - // alert('Copied to clipboard')`} 2128 - // > 2129 - // <i class="fa-solid fa-share-nodes mr-2" /> 2130 - // Share 2131 - // </Button> 2132 - // </> 2133 - // ); 2134 - // } 2135 - 2136 - function FavoriteButton({ 2137 - currentUserDid, 2138 - favs = [], 2139 - galleryUri, 2140 - }: Readonly<{ 2141 - currentUserDid?: string; 2142 - favs: WithBffMeta<Favorite>[]; 2143 - galleryUri: string; 2144 - }>) { 2145 - const favUri = favs.find((s) => currentUserDid === s.did)?.uri; 2146 - return ( 2147 - <Button 2148 - variant="primary" 2149 - class="self-start w-full sm:w-fit" 2150 - type="button" 2151 - hx-post={`/actions/favorite?galleryUri=${galleryUri}${ 2152 - favUri ? "&favUri=" + favUri : "" 2153 - }`} 2154 - hx-target="this" 2155 - hx-swap="outerHTML" 2156 - > 2157 - <i class={cn("fa-heart", favUri ? "fa-solid" : "fa-regular")}></i>{" "} 2158 - {favs.length} 2159 - </Button> 2160 - ); 2161 - } 2162 - 2163 - function GalleryCreateEditDialog({ 2164 - gallery, 2165 - }: Readonly<{ gallery?: GalleryView | null }>) { 2166 - return ( 2167 - <Dialog id="gallery-dialog" class="z-30"> 2168 - <Dialog.Content class="dark:bg-zinc-950 relative"> 2169 - <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 2170 - <Dialog.Title> 2171 - {gallery ? "Edit gallery" : "Create a new gallery"} 2172 - </Dialog.Title> 2173 - <form 2174 - id="gallery-form" 2175 - class="max-w-xl" 2176 - hx-post={`/actions/create-edit${ 2177 - gallery ? "?uri=" + gallery?.uri : "" 2178 - }`} 2179 - hx-swap="none" 2180 - _="on htmx:afterOnLoad 2181 - if event.detail.xhr.status != 200 2182 - alert('Error: ' + event.detail.xhr.responseText)" 2183 - > 2184 - <div class="mb-4 relative"> 2185 - <label htmlFor="title">Gallery name</label> 2186 - <Input 2187 - type="text" 2188 - id="title" 2189 - name="title" 2190 - class="dark:bg-zinc-800 dark:text-white" 2191 - required 2192 - value={(gallery?.record as Gallery)?.title} 2193 - autofocus 2194 - /> 2195 - </div> 2196 - <div class="mb-2 relative"> 2197 - <label htmlFor="description">Description</label> 2198 - <Textarea 2199 - id="description" 2200 - name="description" 2201 - rows={4} 2202 - class="dark:bg-zinc-800 dark:text-white" 2203 - > 2204 - {(gallery?.record as Gallery)?.description} 2205 - </Textarea> 2206 - </div> 2207 - </form> 2208 - <div class="max-w-xl"> 2209 - <input 2210 - type="button" 2211 - name="galleryUri" 2212 - value={gallery?.uri} 2213 - class="hidden" 2214 - /> 2215 - </div> 2216 - <form 2217 - id="delete-form" 2218 - hx-post={`/actions/gallery/delete?uri=${gallery?.uri}`} 2219 - > 2220 - <input type="hidden" name="uri" value={gallery?.uri} /> 2221 - </form> 2222 - <div class="flex flex-col gap-2 mt-2"> 2223 - <Button 2224 - variant="primary" 2225 - form="gallery-form" 2226 - type="submit" 2227 - class="w-full" 2228 - > 2229 - {gallery ? "Update gallery" : "Create gallery"} 2230 - </Button> 2231 - {gallery 2232 - ? ( 2233 - <Button 2234 - variant="destructive" 2235 - form="delete-form" 2236 - type="submit" 2237 - class="w-full" 2238 - > 2239 - Delete gallery 2240 - </Button> 2241 - ) 2242 - : null} 2243 - <Button 2244 - variant="secondary" 2245 - type="button" 2246 - class="w-full" 2247 - _={Dialog._closeOnClick} 2248 - > 2249 - Cancel 2250 - </Button> 2251 - </div> 2252 - </Dialog.Content> 2253 - </Dialog> 2254 - ); 2255 - } 2256 - 2257 - function PhotoPreview({ 2258 - src, 2259 - uri, 2260 - }: Readonly<{ 2261 - src: string; 2262 - uri?: string; 2263 - }>) { 2264 - return ( 2265 - <div class="relative aspect-square bg-zinc-200 dark:bg-zinc-900"> 2266 - {uri ? <AltTextButton photoUri={uri} /> : null} 2267 - {uri 2268 - ? ( 2269 - <button 2270 - type="button" 2271 - hx-delete={`/actions/photo/${new AtUri(uri).rkey}`} 2272 - class="bg-zinc-950 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center" 2273 - _="on htmx:afterOnLoad remove me.parentNode" 2274 - > 2275 - <i class="fas fa-close text-white"></i> 2276 - </button> 2277 - ) 2278 - : null} 2279 - <img 2280 - src={src} 2281 - alt="" 2282 - data-state={uri ? "complete" : "pending"} 2283 - class="absolute inset-0 w-full h-full object-contain data-[state=pending]:opacity-50" 2284 - /> 2285 - </div> 2286 - ); 2287 - } 2288 - 2289 - function AltTextButton({ photoUri }: Readonly<{ photoUri: string }>) { 2290 - return ( 2291 - <div 2292 - 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" 2293 - hx-get={`/dialogs/photo/${new AtUri(photoUri).rkey}/alt`} 2294 - hx-trigger="click" 2295 - hx-target="#layout" 2296 - hx-swap="afterbegin" 2297 - _="on click halt" 2298 - > 2299 - <i class="fas fa-plus text-[10px] mr-1"></i> ALT 2300 - </div> 2301 - ); 2302 - } 2303 - 2304 - function PhotoDialog({ 2305 - gallery, 2306 - image, 2307 - nextImage, 2308 - prevImage, 2309 - }: Readonly<{ 2310 - gallery: GalleryView; 2311 - image: PhotoView; 2312 - nextImage?: PhotoView; 2313 - prevImage?: PhotoView; 2314 - }>) { 2315 - return ( 2316 - <Dialog id="photo-dialog" class="bg-zinc-950 z-30"> 2317 - <Dialog.X /> 2318 - {nextImage 2319 - ? ( 2320 - <div 2321 - hx-get={photoDialogLink(gallery, nextImage)} 2322 - hx-trigger="keyup[key=='ArrowRight'] from:body, swipeleft from:body" 2323 - hx-target="#photo-dialog" 2324 - hx-swap="innerHTML" 2325 - /> 2326 - ) 2327 - : null} 2328 - {prevImage 2329 - ? ( 2330 - <div 2331 - hx-get={photoDialogLink(gallery, prevImage)} 2332 - hx-trigger="keyup[key=='ArrowLeft'] from:body, swiperight from:body" 2333 - hx-target="#photo-dialog" 2334 - hx-swap="innerHTML" 2335 - /> 2336 - ) 2337 - : null} 2338 - <div 2339 - class="flex flex-col w-5xl h-[calc(100vh-100px)] sm:h-screen z-20" 2340 - _={Dialog._closeOnClick} 2341 - > 2342 - <div class="flex flex-col p-4 z-20 flex-1 relative"> 2343 - <img 2344 - src={image.fullsize} 2345 - alt={image.alt} 2346 - class="absolute inset-0 w-full h-full object-contain" 2347 - /> 2348 - </div> 2349 - {image.alt 2350 - ? ( 2351 - <div class="px-4 sm:px-0 py-4 bg-black text-white text-left"> 2352 - {image.alt} 2353 - </div> 2354 - ) 2355 - : null} 2356 - </div> 2357 - </Dialog> 2358 - ); 2359 - } 2360 - 2361 - function PhotoAltDialog({ 2362 - photo, 2363 - }: Readonly<{ 2364 - photo: PhotoView; 2365 - }>) { 2366 - return ( 2367 - <Dialog id="photo-alt-dialog" class="z-30"> 2368 - <Dialog.Content class="dark:bg-zinc-950 relative"> 2369 - <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 2370 - <Dialog.Title>Add alt text</Dialog.Title> 2371 - <div class="aspect-square relative"> 2372 - <img 2373 - src={photo.fullsize} 2374 - alt={photo.alt} 2375 - class="absolute inset-0 w-full h-full object-contain" 2376 - /> 2377 - </div> 2378 - <form 2379 - hx-put={`/actions/photo/${new AtUri(photo.uri).rkey}`} 2380 - _="on htmx:afterOnLoad trigger closeDialog" 2381 - > 2382 - <div class="my-2"> 2383 - <label htmlFor="alt">Descriptive alt text</label> 2384 - <Textarea 2385 - id="alt" 2386 - name="alt" 2387 - rows={4} 2388 - defaultValue={photo.alt} 2389 - placeholder="Alt text" 2390 - autoFocus 2391 - class="dark:bg-zinc-800 dark:text-white" 2392 - /> 2393 - </div> 2394 - <div class="w-full flex flex-col gap-2 mt-2"> 2395 - <Button type="submit" variant="primary" class="w-full"> 2396 - Save 2397 - </Button> 2398 - <Dialog.Close class="w-full">Cancel</Dialog.Close> 2399 - </div> 2400 - </form> 2401 - </Dialog.Content> 2402 - </Dialog> 2403 - ); 2404 - } 2405 - 2406 - function PhotoSelectDialog({ 2407 - galleryUri, 2408 - itemUris, 2409 - photos, 2410 - }: Readonly<{ 2411 - galleryUri: string; 2412 - itemUris: string[]; 2413 - photos: PhotoView[]; 2414 - }>) { 2415 - return ( 2416 - <Dialog id="photo-select-dialog" class="z-30"> 2417 - <Dialog.Content class="w-full max-w-5xl dark:bg-zinc-950 sm:min-h-screen flex flex-col relative"> 2418 - <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 2419 - <Dialog.Title>Add photos</Dialog.Title> 2420 - {photos.length 2421 - ? ( 2422 - <p class="my-2 text-center"> 2423 - Choose photos to add/remove from your gallery. Click close when 2424 - done. 2425 - </p> 2426 - ) 2427 - : null} 2428 - {photos.length 2429 - ? ( 2430 - <div class="grid grid-cols-3 sm:grid-cols-5 gap-4 my-4 flex-1"> 2431 - {photos.map((photo) => ( 2432 - <PhotoSelectButton 2433 - key={photo.cid} 2434 - galleryUri={galleryUri} 2435 - itemUris={itemUris} 2436 - photo={photo} 2437 - /> 2438 - ))} 2439 - </div> 2440 - ) 2441 - : ( 2442 - <div class="flex-1 flex justify-center items-center my-30"> 2443 - <p> 2444 - No photos yet.{" "} 2445 - <a 2446 - href={`/upload?returnTo=${new AtUri(galleryUri).rkey}`} 2447 - class="hover:underline font-semibold text-sky-500" 2448 - > 2449 - Upload 2450 - </a>{" "} 2451 - photos and return to add. 2452 - </p> 2453 - </div> 2454 - )} 2455 - <div class="w-full flex flex-col gap-2 mt-2"> 2456 - <Dialog.Close class="w-full">Close</Dialog.Close> 2457 - </div> 2458 - </Dialog.Content> 2459 - </Dialog> 2460 - ); 2461 - } 2462 - 2463 - function PhotoSelectButton({ 2464 - galleryUri, 2465 - itemUris, 2466 - photo, 2467 - }: Readonly<{ 2468 - galleryUri: string; 2469 - itemUris: string[]; 2470 - photo: PhotoView; 2471 - }>) { 2472 - return ( 2473 - <button 2474 - hx-put={`/actions/gallery/${new AtUri(galleryUri).rkey}/${ 2475 - itemUris.includes(photo.uri) ? "remove-photo" : "add-photo" 2476 - }/${new AtUri(photo.uri).rkey}`} 2477 - hx-swap="outerHTML" 2478 - type="button" 2479 - data-added={itemUris.includes(photo.uri) ? "true" : "false"} 2480 - class="group cursor-pointer relative aspect-square data-[added=true]:ring-2 ring-sky-500 disabled:opacity-50" 2481 - _={`on htmx:beforeRequest add @disabled to me 2482 - then on htmx:afterOnLoad 2483 - remove @disabled from me 2484 - if @data-added == 'true' 2485 - set @data-added to 'false' 2486 - remove #photo-${new AtUri(photo.uri).rkey} 2487 - else 2488 - set @data-added to 'true' 2489 - end`} 2490 - > 2491 - <div class="hidden group-data-[added=true]:block absolute top-2 right-2 z-30"> 2492 - <i class="fa-check fa-solid text-sky-500 z-10" /> 2493 - </div> 2494 - <img 2495 - src={photo.fullsize} 2496 - alt={photo.alt} 2497 - class="absolute inset-0 w-full h-full object-contain" 2498 - /> 2499 - </button> 2500 - ); 2501 - } 2502 - 2503 - function getActorProfile(did: string, ctx: BffContext) { 2504 - const actor = ctx.indexService.getActor(did); 2505 - if (!actor) return null; 2506 - const profileRecord = ctx.indexService.getRecord<WithBffMeta<Profile>>( 2507 - `at://${did}/social.grain.actor.profile/self`, 2508 - ); 2509 - return profileRecord ? profileToView(profileRecord, actor.handle) : null; 2510 - } 2511 - 2512 - function galleryToView( 2513 - record: WithBffMeta<Gallery>, 2514 - creator: Un$Typed<ProfileView>, 2515 - items: Photo[], 2516 - ): Un$Typed<GalleryView> { 2517 - return { 2518 - uri: record.uri, 2519 - cid: record.cid, 2520 - creator, 2521 - record, 2522 - items: items 2523 - ?.map((item) => itemToView(record.did, item)) 2524 - .filter(isPhotoView), 2525 - indexedAt: record.indexedAt, 2526 - }; 2527 - } 2528 - 2529 - function itemToView( 2530 - did: string, 2531 - item: 2532 - | WithBffMeta<Photo> 2533 - | { 2534 - $type: string; 2535 - }, 2536 - ): Un$Typed<PhotoView> | undefined { 2537 - if (isPhoto(item)) { 2538 - return photoToView(did, item); 2539 - } 2540 - return undefined; 2541 - } 2542 - 2543 - function photoThumb(did: string, cid: string) { 2544 - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`; 2545 - } 2546 - 2547 - function photoToView( 2548 - did: string, 2549 - photo: WithBffMeta<Photo>, 2550 - ): $Typed<PhotoView> { 2551 - return { 2552 - $type: "social.grain.photo.defs#photoView", 2553 - uri: photo.uri, 2554 - cid: photo.photo.ref.toString(), 2555 - thumb: 2556 - `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${photo.photo.ref.toString()}@jpeg`, 2557 - fullsize: 2558 - `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${photo.photo.ref.toString()}@jpeg`, 2559 - alt: photo.alt, 2560 - aspectRatio: photo.aspectRatio, 2561 - }; 2562 - } 2563 - 2564 - function profileToView( 2565 - record: WithBffMeta<Profile>, 2566 - handle: string, 2567 - ): Un$Typed<ProfileView> { 2568 - return { 2569 - did: record.did, 2570 - handle, 2571 - displayName: record.displayName, 2572 - description: record.description, 2573 - avatar: record?.avatar 2574 - ? `https://cdn.bsky.app/img/feed_thumbnail/plain/${record.did}/${record.avatar.ref.toString()}` 2575 - : undefined, 2576 - }; 2577 - } 2578 - 2579 - type NotificationRecords = WithBffMeta<Favorite>; 2580 - 2581 - function notificationToView( 2582 - record: NotificationRecords, 2583 - author: Un$Typed<ProfileView>, 2584 - lastSeenNotifs: string | undefined, 2585 - ): Un$Typed<NotificationView> { 2586 - const reason = record.$type === "social.grain.favorite" 2587 - ? "gallery-favorite" 2588 - : "unknown"; 2589 - const reasonSubject = record.$type === "social.grain.favorite" 2590 - ? record.subject 2591 - : undefined; 2592 - const isRead = lastSeenNotifs ? record.createdAt <= lastSeenNotifs : false; 2593 - return { 2594 - uri: record.uri, 2595 - cid: record.cid, 2596 - author, 2597 - record, 2598 - reason, 2599 - reasonSubject, 2600 - isRead, 2601 - indexedAt: record.indexedAt, 2602 - }; 2603 - } 2604 - 2605 - function profileLink(handle: string) { 2606 - return `/profile/${handle}`; 2607 - } 2608 - 2609 - function galleryLink(handle: string, galleryRkey: string) { 2610 - return `/profile/${handle}/gallery/${galleryRkey}`; 2611 - } 2612 - 2613 - function photoDialogLink(gallery: GalleryView, image: PhotoView) { 2614 - return `/dialogs/image?galleryUri=${gallery.uri}&imageCid=${image.cid}`; 2615 - } 2616 - 2617 - async function onSignedIn({ actor, ctx }: onSignedInArgs) { 2618 - await ctx.backfillCollections( 2619 - [actor.did], 2620 - [ 2621 - ...ctx.cfg.collections!, 2622 - "app.bsky.actor.profile", 2623 - "app.bsky.graph.follow", 2624 - ], 2625 - ); 2626 - 2627 - const profileResults = ctx.indexService.getRecords<Profile>( 2628 - "social.grain.actor.profile", 2629 - { 2630 - where: [{ field: "did", equals: actor.did }], 2631 - }, 2632 - ); 2633 - 2634 - const profile = profileResults.items[0]; 2635 - 2636 - if (profile) { 2637 - console.log("Profile already exists"); 2638 - return `/profile/${actor.handle}`; 2639 - } 2640 - 2641 - const bskyProfileResults = ctx.indexService.getRecords<BskyProfile>( 2642 - "app.bsky.actor.profile", 2643 - { 2644 - where: [{ field: "did", equals: actor.did }], 2645 - }, 2646 - ); 2647 - 2648 - const bskyProfile = bskyProfileResults.items[0]; 2649 - 2650 - if (!bskyProfile) { 2651 - console.error("Failed to get profile"); 2652 - return; 2653 - } 2654 - 2655 - await ctx.createRecord<Profile>( 2656 - "social.grain.actor.profile", 2657 - { 2658 - displayName: bskyProfile.displayName ?? undefined, 2659 - description: bskyProfile.description ?? undefined, 2660 - avatar: bskyProfile.avatar ?? undefined, 2661 - createdAt: new Date().toISOString(), 2662 - }, 2663 - true, 2664 - ); 2665 - 2666 - return "/onboard"; 2667 - } 2668 - 2669 - function uploadStart( 2670 - routePrefix: string, 2671 - cb: (params: { uploadId: string; src: string; done?: boolean }) => VNode, 2672 - ): RouteHandler { 2673 - return async (req, _params, ctx) => { 2674 - ctx.requireAuth(); 2675 - ctx.rateLimit({ 2676 - namespace: "upload", 2677 - points: 1, 2678 - limit: 50, 2679 - window: 24 * 60 * 60 * 1000, // 24 hours 2680 - }); 2681 - const formData = await req.formData(); 2682 - const file = formData.get("file") as File; 2683 - if (!file) { 2684 - return new Response("No file", { status: 400 }); 2685 - } 2686 - const dataUrl = await compressImageForPreview(file); 2687 - if (!ctx.agent) { 2688 - return new Response("No agent", { status: 400 }); 2689 - } 2690 - await photoProcessor.initialize(ctx.agent); 2691 - const uploadId = photoProcessor.startUpload(file); 2692 - return ctx.html( 2693 - <div 2694 - id={`upload-id-${uploadId}`} 2695 - hx-trigger="done" 2696 - hx-get={`/actions/${routePrefix}/upload-done?uploadId=${uploadId}`} 2697 - hx-target="this" 2698 - hx-swap="outerHTML" 2699 - class="h-full w-full" 2700 - > 2701 - <div 2702 - hx-get={`/actions/${routePrefix}/upload-check-status?uploadId=${uploadId}`} 2703 - hx-trigger="every 600ms" 2704 - hx-target="this" 2705 - hx-swap="innerHTML" 2706 - class="h-full w-full" 2707 - > 2708 - {cb({ uploadId, src: dataUrl })} 2709 - </div> 2710 - </div>, 2711 - ); 2712 - }; 2713 - } 2714 - 2715 - function uploadCheckStatus(): RouteHandler { 2716 - return (req, _params, ctx) => { 2717 - ctx.requireAuth(); 2718 - const url = new URL(req.url); 2719 - const searchParams = new URLSearchParams(url.search); 2720 - const uploadId = searchParams.get("uploadId"); 2721 - if (!uploadId) return ctx.next(); 2722 - const meta = photoProcessor.getUploadStatus(uploadId); 2723 - return new Response( 2724 - null, 2725 - { 2726 - status: meta?.blobRef ? 200 : 204, 2727 - headers: meta?.blobRef ? { "HX-Trigger": "done" } : {}, 2728 - }, 2729 - ); 2730 - }; 2731 - } 2732 - 2733 - function avatarUploadDone( 2734 - cb: (params: { src: string; uploadId: string }) => VNode, 2735 - ): RouteHandler { 2736 - return (req, _params, ctx) => { 2737 - const { did } = ctx.requireAuth(); 2738 - const url = new URL(req.url); 2739 - const searchParams = new URLSearchParams(url.search); 2740 - const uploadId = searchParams.get("uploadId"); 2741 - if (!uploadId) return ctx.next(); 2742 - const meta = photoProcessor.getUploadStatus(uploadId); 2743 - if (!meta?.blobRef) return ctx.next(); 2744 - return ctx.html( 2745 - cb({ src: photoThumb(did, meta.blobRef.ref.toString()), uploadId }), 2746 - ); 2747 - }; 2748 - } 2749 - 2750 - function photoUploadDone( 2751 - cb: (params: { src: string; uri: string }) => VNode, 2752 - ): RouteHandler { 2753 - return async (req, _params, ctx) => { 2754 - const { did } = ctx.requireAuth(); 2755 - const url = new URL(req.url); 2756 - const searchParams = new URLSearchParams(url.search); 2757 - const uploadId = searchParams.get("uploadId"); 2758 - if (!uploadId) return ctx.next(); 2759 - const meta = photoProcessor.getUploadStatus(uploadId); 2760 - if (!meta?.blobRef) return ctx.next(); 2761 - const photoUri = await ctx.createRecord<Photo>("social.grain.photo", { 2762 - photo: meta.blobRef, 2763 - aspectRatio: meta.dimensions?.width && meta.dimensions?.height 2764 - ? { 2765 - width: meta.dimensions.width, 2766 - height: meta.dimensions.height, 2767 - } 2768 - : undefined, 2769 - alt: "", 2770 - createdAt: new Date().toISOString(), 2771 - }); 2772 - return ctx.html( 2773 - cb({ src: photoThumb(did, meta.blobRef.ref.toString()), uri: photoUri }), 2774 - ); 2775 - }; 2776 - } 2777 - 2778 - function photoUploadRoutes(): BffMiddleware[] { 2779 - return [ 2780 - route( 2781 - `/actions/photo/upload-start`, 2782 - ["POST"], 2783 - uploadStart( 2784 - "photo", 2785 - ({ src }) => <PhotoPreview src={src} />, 2786 - ), 2787 - ), 2788 - route( 2789 - `/actions/photo/upload-check-status`, 2790 - ["GET"], 2791 - uploadCheckStatus(), 2792 - ), 2793 - route( 2794 - `/actions/photo/upload-done`, 2795 - ["GET"], 2796 - photoUploadDone(({ src, uri }) => ( 2797 - <PhotoPreview 2798 - src={src} 2799 - uri={uri} 2800 - /> 2801 - )), 2802 - ), 2803 - ]; 2804 - } 2805 - 2806 - function avatarUploadRoutes(): BffMiddleware[] { 2807 - return [ 2808 - route( 2809 - `/actions/avatar/upload-start`, 2810 - ["POST"], 2811 - uploadStart("avatar", ({ src }) => ( 2812 - <img 2813 - src={src} 2814 - alt="" 2815 - data-state="pending" 2816 - class="rounded-full w-full h-full object-cover data-[state=pending]:opacity-50" 2817 - /> 2818 - )), 2819 - ), 2820 - route( 2821 - `/actions/avatar/upload-check-status`, 2822 - ["GET"], 2823 - uploadCheckStatus(), 2824 - ), 2825 - route( 2826 - `/actions/avatar/upload-done`, 2827 - ["GET"], 2828 - avatarUploadDone(({ src, uploadId }) => ( 2829 - <> 2830 - <div hx-swap-oob="innerHTML:#image-input"> 2831 - <input type="hidden" name="uploadId" value={uploadId} /> 2832 - </div> 2833 - <img 2834 - src={src} 2835 - alt="" 2836 - class="rounded-full w-full h-full object-cover" 2837 - /> 2838 - </> 2839 - )), 2840 - ), 2841 - ]; 2842 - } 2843 - 2844 - function publicGalleryLink(handle: string, galleryUri: string): string { 2845 - return `${PUBLIC_URL}${galleryLink(handle, new AtUri(galleryUri).rkey)}`; 2846 - }
+74
src/actor.ts
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 4 + import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 5 + import { Un$Typed } from "$lexicon/util.ts"; 6 + import { BffContext, WithBffMeta } from "@bigmoves/bff"; 7 + import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 8 + import { photoToView } from "./photo.ts"; 9 + 10 + export function getActorProfile(did: string, ctx: BffContext) { 11 + const actor = ctx.indexService.getActor(did); 12 + if (!actor) return null; 13 + const profileRecord = ctx.indexService.getRecord<WithBffMeta<Profile>>( 14 + `at://${did}/social.grain.actor.profile/self`, 15 + ); 16 + return profileRecord ? profileToView(profileRecord, actor.handle) : null; 17 + } 18 + 19 + function profileToView( 20 + record: WithBffMeta<Profile>, 21 + handle: string, 22 + ): Un$Typed<ProfileView> { 23 + return { 24 + did: record.did, 25 + handle, 26 + displayName: record.displayName, 27 + description: record.description, 28 + avatar: record?.avatar 29 + ? `https://cdn.bsky.app/img/feed_thumbnail/plain/${record.did}/${record.avatar.ref.toString()}` 30 + : undefined, 31 + }; 32 + } 33 + 34 + export function getActorPhotos(handleOrDid: string, ctx: BffContext) { 35 + let did: string; 36 + if (handleOrDid.includes("did:")) { 37 + did = handleOrDid; 38 + } else { 39 + const actor = ctx.indexService.getActorByHandle(handleOrDid); 40 + if (!actor) return []; 41 + did = actor.did; 42 + } 43 + const photos = ctx.indexService.getRecords<WithBffMeta<Photo>>( 44 + "social.grain.photo", 45 + { 46 + where: [{ field: "did", equals: did }], 47 + orderBy: [{ field: "createdAt", direction: "desc" }], 48 + }, 49 + ); 50 + return photos.items.map((photo) => photoToView(photo.did, photo)); 51 + } 52 + 53 + export function getActorGalleries(handleOrDid: string, ctx: BffContext) { 54 + let did: string; 55 + if (handleOrDid.includes("did:")) { 56 + did = handleOrDid; 57 + } else { 58 + const actor = ctx.indexService.getActorByHandle(handleOrDid); 59 + if (!actor) return []; 60 + did = actor.did; 61 + } 62 + const { items: galleries } = ctx.indexService.getRecords< 63 + WithBffMeta<Gallery> 64 + >("social.grain.gallery", { 65 + where: [{ field: "did", equals: did }], 66 + orderBy: [{ field: "createdAt", direction: "desc" }], 67 + }); 68 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 69 + const creator = getActorProfile(did, ctx); 70 + if (!creator) return []; 71 + return galleries.map((gallery) => 72 + galleryToView(gallery, creator, galleryPhotosMap.get(gallery.uri) ?? []) 73 + ); 74 + }
+76
src/app.tsx
··· 1 + import { CSS, RootProps } from "@bigmoves/bff"; 2 + import { Layout, Meta } from "@bigmoves/bff/components"; 3 + import { GOATCOUNTER_URL } from "./env.ts"; 4 + import type { State } from "./state.ts"; 5 + 6 + export function Root(props: Readonly<RootProps<State>>) { 7 + const profile = props.ctx.state.profile; 8 + const scripts = props.ctx.state.scripts; 9 + const hasNotifications = 10 + props.ctx.state.notifications?.find((n) => n.isRead === false) !== 11 + undefined; 12 + const staticFilesHash = props.ctx.state.staticFilesHash; 13 + return ( 14 + <html lang="en" class="w-full h-full"> 15 + <head> 16 + <meta charset="UTF-8" /> 17 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 18 + <Meta meta={props.ctx.state.meta} /> 19 + {GOATCOUNTER_URL 20 + ? ( 21 + <script 22 + data-goatcounter={GOATCOUNTER_URL} 23 + async 24 + src="//gc.zgo.at/count.js" 25 + /> 26 + ) 27 + : null} 28 + <script src="https://unpkg.com/htmx.org@1.9.10" /> 29 + <script src="https://unpkg.com/hyperscript.org@0.9.14" /> 30 + <script src="https://unpkg.com/sortablejs@1.15.6" /> 31 + <style dangerouslySetInnerHTML={{ __html: CSS }} /> 32 + <link 33 + rel="stylesheet" 34 + href={`/static/styles.css?${staticFilesHash?.get("styles.css")}`} 35 + /> 36 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 37 + <link 38 + rel="preconnect" 39 + href="https://fonts.gstatic.com" 40 + crossOrigin="anonymous" 41 + /> 42 + <link 43 + href="https://fonts.googleapis.com/css2?family=Jersey+20&display=swap" 44 + rel="stylesheet" 45 + /> 46 + <link 47 + rel="stylesheet" 48 + href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 49 + preload 50 + /> 51 + {scripts?.map((file) => ( 52 + <script 53 + key={file} 54 + src={`/static/${file}?${staticFilesHash?.get(file)}`} 55 + /> 56 + ))} 57 + </head> 58 + <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 59 + <Layout id="layout" class="border-zinc-200 dark:border-zinc-800"> 60 + <Layout.Nav 61 + heading={ 62 + <h1 class="font-['Jersey_20'] text-4xl text-zinc-900 dark:text-white"> 63 + grain 64 + <sub class="bottom-[0.75rem] text-[1rem]">beta</sub> 65 + </h1> 66 + } 67 + profile={profile} 68 + hasNotifications={hasNotifications} 69 + class="border-zinc-200 dark:border-zinc-800" 70 + /> 71 + <Layout.Content>{props.children}</Layout.Content> 72 + </Layout> 73 + </body> 74 + </html> 75 + ); 76 + }
+17
src/components/ActorAvatar.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Un$Typed } from "$lexicon/util.ts"; 3 + import { cn } from "@bigmoves/bff/components"; 4 + 5 + export function ActorAvatar({ 6 + profile, 7 + class: classProp, 8 + }: Readonly<{ profile: Un$Typed<ProfileView>; class?: string }>) { 9 + return ( 10 + <img 11 + src={profile.avatar} 12 + alt={profile.handle} 13 + title={profile.handle} 14 + class={cn("rounded-full object-cover", classProp)} 15 + /> 16 + ); 17 + }
+23
src/components/ActorInfo.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Un$Typed } from "$lexicon/util.ts"; 3 + import { profileLink } from "../utils.ts"; 4 + import { ActorAvatar } from "./ActorAvatar.tsx"; 5 + 6 + export function ActorInfo( 7 + { profile }: Readonly<{ profile: Un$Typed<ProfileView> }>, 8 + ) { 9 + return ( 10 + <div class="flex items-center gap-2 min-w-0 flex-1"> 11 + <ActorAvatar profile={profile} class="size-7 shrink-0" /> 12 + <a 13 + href={profileLink(profile.handle)} 14 + class="hover:underline text-zinc-600 dark:text-zinc-500 truncate max-w-[300px] sm:max-w-[400px]" 15 + > 16 + <span class="text-zinc-950 dark:text-zinc-50 font-semibold text-"> 17 + {profile.displayName || profile.handle} 18 + </span>{" "} 19 + <span class="truncate">@{profile.handle}</span> 20 + </a> 21 + </div> 22 + ); 23 + }
+16
src/components/AltTextButton.tsx
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + 3 + export function AltTextButton({ photoUri }: Readonly<{ photoUri: string }>) { 4 + return ( 5 + <div 6 + 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 + hx-get={`/dialogs/photo/${new AtUri(photoUri).rkey}/alt`} 8 + hx-trigger="click" 9 + hx-target="#layout" 10 + hx-swap="afterbegin" 11 + _="on click halt" 12 + > 13 + <i class="fas fa-plus text-[10px] mr-1"></i> ALT 14 + </div> 15 + ); 16 + }
+23
src/components/AvatarButton.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Un$Typed } from "$lexicon/util.ts"; 3 + 4 + export function AvatarButton({ 5 + profile, 6 + }: Readonly<{ profile: Un$Typed<ProfileView> }>) { 7 + return ( 8 + <button 9 + type="button" 10 + class="cursor-pointer" 11 + hx-get={`/dialogs/avatar/${profile.handle}`} 12 + hx-trigger="click" 13 + hx-target="body" 14 + hx-swap="afterbegin" 15 + > 16 + <img 17 + src={profile.avatar} 18 + alt={profile.handle} 19 + class="rounded-full object-cover size-16" 20 + /> 21 + </button> 22 + ); 23 + }
+20
src/components/AvatarDialog.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Un$Typed } from "$lexicon/util.ts"; 3 + import { Dialog } from "@bigmoves/bff/components"; 4 + import { ActorAvatar } from "./ActorAvatar.tsx"; 5 + 6 + export function AvatarDialog({ 7 + profile, 8 + }: Readonly<{ profile: Un$Typed<ProfileView> }>) { 9 + return ( 10 + <Dialog> 11 + <Dialog.X /> 12 + <div 13 + class="w-[400px] h-[400px] flex flex-col p-4 z-10" 14 + _={Dialog._closeOnClick} 15 + > 16 + <ActorAvatar class="w-full h-full" profile={profile} /> 17 + </div> 18 + </Dialog> 19 + ); 20 + }
+41
src/components/AvatarForm.tsx
··· 1 + export function AvatarForm( 2 + { src, alt }: Readonly<{ src?: string; alt?: string }>, 3 + ) { 4 + return ( 5 + <form 6 + id="avatar-file-form" 7 + hx-post="/actions/avatar/upload-start" 8 + hx-target="#image-preview" 9 + hx-swap="innerHTML" 10 + hx-encoding="multipart/form-data" 11 + hx-trigger="change from:#file" 12 + > 13 + <label htmlFor="file"> 14 + <span class="sr-only">Upload avatar</span> 15 + <div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer"> 16 + <div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 17 + <i class="fa-solid fa-camera text-white text-xs"></i> 18 + </div> 19 + <div id="image-preview" class="w-full h-full"> 20 + {src 21 + ? ( 22 + <img 23 + src={src} 24 + alt={alt} 25 + className="rounded-full w-full h-full object-cover" 26 + /> 27 + ) 28 + : null} 29 + </div> 30 + </div> 31 + <input 32 + class="hidden" 33 + type="file" 34 + id="file" 35 + name="file" 36 + accept="image/*" 37 + /> 38 + </label> 39 + </form> 40 + ); 41 + }
+30
src/components/FavoriteButton.tsx
··· 1 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 + import { WithBffMeta } from "@bigmoves/bff"; 3 + import { Button, cn } from "@bigmoves/bff/components"; 4 + 5 + export function FavoriteButton({ 6 + currentUserDid, 7 + favs = [], 8 + galleryUri, 9 + }: Readonly<{ 10 + currentUserDid?: string; 11 + favs: WithBffMeta<Favorite>[]; 12 + galleryUri: string; 13 + }>) { 14 + const favUri = favs.find((s) => currentUserDid === s.did)?.uri; 15 + return ( 16 + <Button 17 + variant="primary" 18 + class="self-start w-full sm:w-fit" 19 + type="button" 20 + hx-post={`/actions/favorite?galleryUri=${galleryUri}${ 21 + favUri ? "&favUri=" + favUri : "" 22 + }`} 23 + hx-target="this" 24 + hx-swap="outerHTML" 25 + > 26 + <i class={cn("fa-heart", favUri ? "fa-solid" : "fa-regular")}></i>{" "} 27 + {favs.length} 28 + </Button> 29 + ); 30 + }
+38
src/components/FollowButton.tsx
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { Button, cn } from "@bigmoves/bff/components"; 3 + 4 + export function FollowButton({ 5 + followeeDid, 6 + followUri, 7 + }: Readonly<{ followeeDid: string; load?: boolean; followUri?: string }>) { 8 + const isFollowing = followUri; 9 + return ( 10 + <Button 11 + variant="primary" 12 + class={cn( 13 + "w-full sm:w-fit", 14 + isFollowing && 15 + "bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50", 16 + )} 17 + {...(isFollowing 18 + ? { 19 + children: "Following", 20 + "hx-delete": `/actions/follow/${followeeDid}/${ 21 + new AtUri(followUri).rkey 22 + }`, 23 + } 24 + : { 25 + children: ( 26 + <> 27 + <i class="fa-solid fa-plus mr-2" /> 28 + Follow 29 + </> 30 + ), 31 + "hx-post": `/actions/follow/${followeeDid}`, 32 + })} 33 + hx-trigger="click" 34 + hx-target="this" 35 + hx-swap="outerHTML" 36 + /> 37 + ); 38 + }
+97
src/components/GalleryCreateEditDialog.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 { Button, Dialog, Input, Textarea } from "@bigmoves/bff/components"; 4 + 5 + export function GalleryCreateEditDialog({ 6 + gallery, 7 + }: Readonly<{ gallery?: GalleryView | null }>) { 8 + return ( 9 + <Dialog id="gallery-dialog" class="z-30"> 10 + <Dialog.Content class="dark:bg-zinc-950 relative"> 11 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 12 + <Dialog.Title> 13 + {gallery ? "Edit gallery" : "Create a new gallery"} 14 + </Dialog.Title> 15 + <form 16 + id="gallery-form" 17 + class="max-w-xl" 18 + hx-post={`/actions/create-edit${ 19 + gallery ? "?uri=" + gallery?.uri : "" 20 + }`} 21 + hx-swap="none" 22 + _="on htmx:afterOnLoad 23 + if event.detail.xhr.status != 200 24 + alert('Error: ' + event.detail.xhr.responseText)" 25 + > 26 + <div class="mb-4 relative"> 27 + <label htmlFor="title">Gallery name</label> 28 + <Input 29 + type="text" 30 + id="title" 31 + name="title" 32 + class="dark:bg-zinc-800 dark:text-white" 33 + required 34 + value={(gallery?.record as Gallery)?.title} 35 + autofocus 36 + /> 37 + </div> 38 + <div class="mb-2 relative"> 39 + <label htmlFor="description">Description</label> 40 + <Textarea 41 + id="description" 42 + name="description" 43 + rows={4} 44 + class="dark:bg-zinc-800 dark:text-white" 45 + > 46 + {(gallery?.record as Gallery)?.description} 47 + </Textarea> 48 + </div> 49 + </form> 50 + <div class="max-w-xl"> 51 + <input 52 + type="button" 53 + name="galleryUri" 54 + value={gallery?.uri} 55 + class="hidden" 56 + /> 57 + </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 + <div class="flex flex-col gap-2 mt-2"> 65 + <Button 66 + variant="primary" 67 + form="gallery-form" 68 + type="submit" 69 + class="w-full" 70 + > 71 + {gallery ? "Update gallery" : "Create gallery"} 72 + </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 + <Button 86 + variant="secondary" 87 + type="button" 88 + class="w-full" 89 + _={Dialog._closeOnClick} 90 + > 91 + Cancel 92 + </Button> 93 + </div> 94 + </Dialog.Content> 95 + </Dialog> 96 + ); 97 + }
+204
src/components/GalleryPage.tsx
··· 1 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 3 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 4 + import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { WithBffMeta } from "@bigmoves/bff"; 7 + import { Button } from "@bigmoves/bff/components"; 8 + import { ActorInfo } from "./ActorInfo.tsx"; 9 + import { FavoriteButton } from "./FavoriteButton.tsx"; 10 + import { PhotoButton } from "./PhotoButton.tsx"; 11 + import { ShareGalleryButton } from "./ShareGalleryButton.tsx"; 12 + 13 + export function GalleryPage({ 14 + gallery, 15 + favs = [], 16 + currentUserDid, 17 + }: Readonly<{ 18 + gallery: GalleryView; 19 + favs: WithBffMeta<Favorite>[]; 20 + currentUserDid?: string; 21 + }>) { 22 + const isCreator = currentUserDid === gallery.creator.did; 23 + const isLoggedIn = !!currentUserDid; 24 + const description = (gallery.record as Gallery).description; 25 + return ( 26 + <div class="px-4"> 27 + <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4 mb-2"> 28 + <div class="flex flex-col space-y-2 mb-4"> 29 + <h1 class="font-bold text-2xl"> 30 + {(gallery.record as Gallery).title} 31 + </h1> 32 + <ActorInfo profile={gallery.creator} /> 33 + {description ? <p>{description}</p> : null} 34 + </div> 35 + {isLoggedIn && isCreator 36 + ? ( 37 + <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 38 + <Button 39 + variant="primary" 40 + class="self-start w-full sm:w-fit" 41 + hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`} 42 + hx-target="#layout" 43 + hx-swap="afterbegin" 44 + > 45 + Edit 46 + </Button> 47 + <Button 48 + hx-get={`/dialogs/photo-select/${new AtUri(gallery.uri).rkey}`} 49 + hx-target="#layout" 50 + hx-swap="afterbegin" 51 + variant="primary" 52 + class="self-start w-full sm:w-fit" 53 + > 54 + Add photos 55 + </Button> 56 + <Button 57 + variant="primary" 58 + class="self-start w-full sm:w-fit" 59 + hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/sort`} 60 + hx-target="#layout" 61 + hx-swap="afterbegin" 62 + > 63 + Sort order 64 + </Button> 65 + <ShareGalleryButton gallery={gallery} /> 66 + </div> 67 + ) 68 + : null} 69 + {!isCreator 70 + ? ( 71 + <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 72 + <ShareGalleryButton gallery={gallery} /> 73 + <FavoriteButton 74 + currentUserDid={currentUserDid} 75 + favs={favs} 76 + galleryUri={gallery.uri} 77 + /> 78 + </div> 79 + ) 80 + : null} 81 + </div> 82 + <div class="flex justify-end mb-2"> 83 + <Button 84 + id="justified-button" 85 + title="Justified layout" 86 + variant="primary" 87 + class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 88 + _="on click call toggleLayout('justified') 89 + set @data-selected to 'true' 90 + set #masonry-button's @data-selected to 'false'" 91 + > 92 + <svg 93 + width="24" 94 + height="24" 95 + viewBox="0 0 24 24" 96 + xmlns="http://www.w3.org/2000/svg" 97 + > 98 + <rect x="2" y="2" width="8" height="6" fill="currentColor" rx="1" /> 99 + <rect 100 + x="12" 101 + y="2" 102 + width="10" 103 + height="6" 104 + fill="currentColor" 105 + rx="1" 106 + /> 107 + <rect 108 + x="2" 109 + y="10" 110 + width="6" 111 + height="6" 112 + fill="currentColor" 113 + rx="1" 114 + /> 115 + <rect 116 + x="10" 117 + y="10" 118 + width="12" 119 + height="6" 120 + fill="currentColor" 121 + rx="1" 122 + /> 123 + <rect 124 + x="2" 125 + y="18" 126 + width="20" 127 + height="4" 128 + fill="currentColor" 129 + rx="1" 130 + /> 131 + </svg> 132 + </Button> 133 + <Button 134 + id="masonry-button" 135 + title="Masonry layout" 136 + variant="primary" 137 + data-selected="false" 138 + class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 139 + _="on click call toggleLayout('masonry') 140 + set @data-selected to 'true' 141 + set #justified-button's @data-selected to 'false'" 142 + > 143 + <svg 144 + width="24" 145 + height="24" 146 + viewBox="0 0 24 24" 147 + xmlns="http://www.w3.org/2000/svg" 148 + > 149 + <rect x="2" y="2" width="8" height="8" fill="currentColor" rx="1" /> 150 + <rect 151 + x="12" 152 + y="2" 153 + width="8" 154 + height="4" 155 + fill="currentColor" 156 + rx="1" 157 + /> 158 + <rect 159 + x="12" 160 + y="8" 161 + width="8" 162 + height="6" 163 + fill="currentColor" 164 + rx="1" 165 + /> 166 + <rect 167 + x="2" 168 + y="12" 169 + width="8" 170 + height="8" 171 + fill="currentColor" 172 + rx="1" 173 + /> 174 + <rect 175 + x="12" 176 + y="16" 177 + width="8" 178 + height="4" 179 + fill="currentColor" 180 + rx="1" 181 + /> 182 + </svg> 183 + </Button> 184 + </div> 185 + <div 186 + id="masonry-container" 187 + class="h-0 overflow-hidden relative mx-auto w-full" 188 + _="on load or htmx:afterSettle call computeLayout()" 189 + > 190 + {gallery.items?.filter(isPhotoView)?.length 191 + ? gallery?.items 192 + ?.filter(isPhotoView) 193 + ?.map((photo) => ( 194 + <PhotoButton 195 + key={photo.cid} 196 + photo={photo} 197 + gallery={gallery} 198 + /> 199 + )) 200 + : null} 201 + </div> 202 + </div> 203 + ); 204 + }
+56
src/components/GalleryPreviewLink.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 + import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 + import { Un$Typed } from "$lexicon/util.ts"; 4 + import { AtUri } from "@atproto/syntax"; 5 + import { cn } from "@bigmoves/bff/components"; 6 + import { galleryLink } from "../utils.ts"; 7 + 8 + export function GalleryPreviewLink({ 9 + gallery, 10 + size = "default", 11 + }: Readonly<{ gallery: Un$Typed<GalleryView>; size?: "small" | "default" }>) { 12 + const gap = size === "small" ? "gap-1" : "gap-2"; 13 + return ( 14 + <a 15 + href={galleryLink( 16 + gallery.creator.handle, 17 + new AtUri(gallery.uri).rkey, 18 + )} 19 + class={cn("flex w-full max-w-md aspect-[3/2] overflow-hidden", gap)} 20 + > 21 + <div class="w-2/3 h-full"> 22 + <img 23 + src={gallery.items?.filter(isPhotoView)[0].thumb} 24 + alt={gallery.items?.filter(isPhotoView)[0].alt} 25 + class="w-full h-full object-cover" 26 + /> 27 + </div> 28 + <div class={cn("w-1/3 flex flex-col h-full", gap)}> 29 + <div class="h-1/2"> 30 + {gallery.items?.filter(isPhotoView)?.[1] 31 + ? ( 32 + <img 33 + src={gallery.items?.filter(isPhotoView)?.[1] 34 + ?.thumb} 35 + alt={gallery.items?.filter(isPhotoView)?.[1]?.alt} 36 + class="w-full h-full object-cover" 37 + /> 38 + ) 39 + : <div className="w-full h-full bg-zinc-200 dark:bg-zinc-900" />} 40 + </div> 41 + <div class="h-1/2"> 42 + {gallery.items?.filter(isPhotoView)?.[2] 43 + ? ( 44 + <img 45 + src={gallery.items?.filter(isPhotoView)?.[2] 46 + ?.thumb} 47 + alt={gallery.items?.filter(isPhotoView)?.[2]?.alt} 48 + class="w-full h-full object-cover" 49 + /> 50 + ) 51 + : <div className="w-full h-full bg-zinc-200 dark:bg-zinc-900" />} 52 + </div> 53 + </div> 54 + </a> 55 + ); 56 + }
+56
src/components/GallerySortDialog.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 + import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { Button, Dialog } from "@bigmoves/bff/components"; 5 + 6 + export function GallerySortDialog( 7 + { gallery }: Readonly<{ gallery: GalleryView }>, 8 + ) { 9 + return ( 10 + <Dialog> 11 + <Dialog.Content class="dark:bg-zinc-950 relative"> 12 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 13 + <Dialog.Title>Sort gallery</Dialog.Title> 14 + <p class="my-2 text-center">Drag photos to rearrange</p> 15 + <form 16 + hx-post={`/actions/gallery/${new AtUri(gallery.uri).rkey}/sort`} 17 + hx-trigger="submit" 18 + hx-swap="none" 19 + > 20 + <div class="sortable grid grid-cols-3 sm:grid-cols-5 gap-2 mt-2"> 21 + {gallery?.items?.filter(isPhotoView).map((item) => ( 22 + <div 23 + key={item.cid} 24 + class="relative aspect-square cursor-grab" 25 + > 26 + <input type="hidden" name="item" value={item.uri} /> 27 + <img 28 + src={item.fullsize} 29 + alt={item.alt} 30 + class="w-full h-full absolute object-cover" 31 + /> 32 + </div> 33 + ))} 34 + </div> 35 + <div class="flex flex-col gap-2 mt-2"> 36 + <Button 37 + variant="primary" 38 + type="submit" 39 + class="w-full" 40 + > 41 + Save 42 + </Button> 43 + <Button 44 + variant="secondary" 45 + type="button" 46 + class="w-full" 47 + _={Dialog._closeOnClick} 48 + > 49 + Cancel 50 + </Button> 51 + </div> 52 + </form> 53 + </Dialog.Content> 54 + </Dialog> 55 + ); 56 + }
+16
src/components/Header.tsx
··· 1 + import { cn } from "@bigmoves/bff/components"; 2 + import { ComponentChildren, JSX } from "preact"; 3 + 4 + export function Header({ 5 + children, 6 + class: classProp, 7 + ...props 8 + }: Readonly< 9 + JSX.HTMLAttributes<HTMLHeadingElement> & { children: ComponentChildren } 10 + >) { 11 + return ( 12 + <h1 class={cn("text-xl font-semibold", classProp)} {...props}> 13 + {children} 14 + </h1> 15 + ); 16 + }
+23
src/components/LoginPage.tsx
··· 1 + import { Login } from "@bigmoves/bff/components"; 2 + import { profileLink } from "../utils.ts"; 3 + 4 + export function LoginPage({ error }: Readonly<{ error?: string }>) { 5 + return ( 6 + <div 7 + id="login" 8 + class="flex justify-center items-center w-full h-full relative" 9 + style="background-image: url('https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreiewhwu3ro5dv7omedphb62db4koa7qtvyzfhiiypg3ru4tvuxkrjy@jpeg'); background-size: cover; background-position: center;" 10 + > 11 + <Login hx-target="#login" error={error} errorClass="text-white" /> 12 + <div class="absolute bottom-2 right-2 text-white text-sm"> 13 + Photo by{" "} 14 + <a 15 + href={profileLink("chadtmiller.com")} 16 + class="hover:underline font-semibold" 17 + > 18 + @chadtmiller.com 19 + </a> 20 + </div> 21 + </div> 22 + ); 23 + }
+64
src/components/NotificationsPage.tsx
··· 1 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 + import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 3 + import { Un$Typed } from "$lexicon/util.ts"; 4 + import { AtUri } from "@atproto/syntax"; 5 + import { formatRelativeTime, profileLink } from "../utils.ts"; 6 + import { ActorAvatar } from "./ActorAvatar.tsx"; 7 + import { Header } from "./Header.tsx"; 8 + 9 + export function NotificationsPage( 10 + { notifications }: Readonly<{ notifications: Un$Typed<NotificationView>[] }>, 11 + ) { 12 + return ( 13 + <div class="px-4 mb-4"> 14 + <div hx-post="/actions/update-seen" hx-trigger="load delay:1s" /> 15 + <div class="my-4"> 16 + <Header>Notifications</Header> 17 + </div> 18 + <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y"> 19 + {notifications.length 20 + ? ( 21 + notifications.map((notification) => ( 22 + <li 23 + key={notification.uri} 24 + class="flex flex-col gap-4 pb-4" 25 + > 26 + <div class="flex flex-wrap items-center gap-2"> 27 + <a 28 + href={profileLink(notification.author.handle)} 29 + class="flex items-center gap-2 hover:underline" 30 + > 31 + <ActorAvatar 32 + profile={notification.author} 33 + class="h-8 w-8" 34 + /> 35 + <span class="font-semibold break-words"> 36 + {notification.author.displayName ?? 37 + notification.author.handle} 38 + </span> 39 + </a> 40 + <span class="break-words"> 41 + favorited your gallery · {formatRelativeTime( 42 + new Date((notification.record as Favorite).createdAt), 43 + )} 44 + </span> 45 + </div> 46 + <div 47 + hx-get={`/embed/profile/${ 48 + new AtUri(notification.reasonSubject ?? "").hostname 49 + }/gallery/${ 50 + new AtUri(notification.reasonSubject ?? "").rkey 51 + }`} 52 + hx-trigger="load" 53 + hx-target="this" 54 + hx-swap="innerHTML" 55 + class="w-[200px]" 56 + /> 57 + </li> 58 + )) 59 + ) 60 + : <li>No notifications yet.</li>} 61 + </ul> 62 + </div> 63 + ); 64 + }
+48
src/components/PhotoAltDialog.tsx
··· 1 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 + import { AtUri } from "@atproto/syntax"; 3 + import { Button, Dialog, Textarea } from "@bigmoves/bff/components"; 4 + 5 + export function PhotoAltDialog({ 6 + photo, 7 + }: Readonly<{ 8 + photo: PhotoView; 9 + }>) { 10 + return ( 11 + <Dialog id="photo-alt-dialog" class="z-30"> 12 + <Dialog.Content class="dark:bg-zinc-950 relative"> 13 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 14 + <Dialog.Title>Add alt text</Dialog.Title> 15 + <div class="aspect-square relative"> 16 + <img 17 + src={photo.fullsize} 18 + alt={photo.alt} 19 + class="absolute inset-0 w-full h-full object-contain" 20 + /> 21 + </div> 22 + <form 23 + hx-put={`/actions/photo/${new AtUri(photo.uri).rkey}`} 24 + _="on htmx:afterOnLoad trigger closeDialog" 25 + > 26 + <div class="my-2"> 27 + <label htmlFor="alt">Descriptive alt text</label> 28 + <Textarea 29 + id="alt" 30 + name="alt" 31 + rows={4} 32 + defaultValue={photo.alt} 33 + placeholder="Alt text" 34 + autoFocus 35 + class="dark:bg-zinc-800 dark:text-white" 36 + /> 37 + </div> 38 + <div class="w-full flex flex-col gap-2 mt-2"> 39 + <Button type="submit" variant="primary" class="w-full"> 40 + Save 41 + </Button> 42 + <Dialog.Close class="w-full">Cancel</Dialog.Close> 43 + </div> 44 + </form> 45 + </Dialog.Content> 46 + </Dialog> 47 + ); 48 + }
+39
src/components/PhotoButton.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { photoDialogLink } from "../utils.ts"; 5 + 6 + export function PhotoButton({ 7 + photo, 8 + gallery, 9 + }: Readonly<{ 10 + photo: PhotoView; 11 + gallery: GalleryView; 12 + }>) { 13 + return ( 14 + <button 15 + id={`photo-${new AtUri(photo.uri).rkey}`} 16 + type="button" 17 + hx-get={photoDialogLink(gallery, photo)} 18 + hx-trigger="click" 19 + hx-target="#layout" 20 + hx-swap="afterbegin" 21 + class="masonry-tile absolute cursor-pointer" 22 + data-width={photo.aspectRatio?.width} 23 + data-height={photo.aspectRatio?.height} 24 + > 25 + <img 26 + src={photo.fullsize} 27 + alt={photo.alt} 28 + class="w-full h-full object-cover" 29 + /> 30 + {photo.alt 31 + ? ( 32 + <div class="absolute bg-zinc-950 dark:bg-zinc-900 bottom-1 right-1 sm:bottom-1 sm:right-1 text-xs text-white font-semibold py-[1px] px-[3px]"> 33 + ALT 34 + </div> 35 + ) 36 + : null} 37 + </button> 38 + ); 39 + }
+61
src/components/PhotoDialog.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 + import { Dialog } from "https://jsr.io/@bigmoves/bff/0.3.0-beta.21/components/Dialog.tsx"; 4 + import { photoDialogLink } from "../utils.ts"; 5 + 6 + export function PhotoDialog({ 7 + gallery, 8 + image, 9 + nextImage, 10 + prevImage, 11 + }: Readonly<{ 12 + gallery: GalleryView; 13 + image: PhotoView; 14 + nextImage?: PhotoView; 15 + prevImage?: PhotoView; 16 + }>) { 17 + return ( 18 + <Dialog id="photo-dialog" class="bg-zinc-950 z-30"> 19 + <Dialog.X /> 20 + {nextImage 21 + ? ( 22 + <div 23 + hx-get={photoDialogLink(gallery, nextImage)} 24 + hx-trigger="keyup[key=='ArrowRight'] from:body, swipeleft from:body" 25 + hx-target="#photo-dialog" 26 + hx-swap="innerHTML" 27 + /> 28 + ) 29 + : null} 30 + {prevImage 31 + ? ( 32 + <div 33 + hx-get={photoDialogLink(gallery, prevImage)} 34 + hx-trigger="keyup[key=='ArrowLeft'] from:body, swiperight from:body" 35 + hx-target="#photo-dialog" 36 + hx-swap="innerHTML" 37 + /> 38 + ) 39 + : null} 40 + <div 41 + class="flex flex-col w-5xl h-[calc(100vh-100px)] sm:h-screen z-20" 42 + _={Dialog._closeOnClick} 43 + > 44 + <div class="flex flex-col p-4 z-20 flex-1 relative"> 45 + <img 46 + src={image.fullsize} 47 + alt={image.alt} 48 + class="absolute inset-0 w-full h-full object-contain" 49 + /> 50 + </div> 51 + {image.alt 52 + ? ( 53 + <div class="px-4 sm:px-0 py-4 bg-black text-white text-left"> 54 + {image.alt} 55 + </div> 56 + ) 57 + : null} 58 + </div> 59 + </Dialog> 60 + ); 61 + }
+34
src/components/PhotoPreview.tsx
··· 1 + import { AtUri } from "@atproto/syntax"; 2 + import { AltTextButton } from "./AltTextButton.tsx"; 3 + 4 + export function PhotoPreview({ 5 + src, 6 + uri, 7 + }: Readonly<{ 8 + src: string; 9 + uri?: string; 10 + }>) { 11 + return ( 12 + <div class="relative aspect-square bg-zinc-200 dark:bg-zinc-900"> 13 + {uri ? <AltTextButton photoUri={uri} /> : null} 14 + {uri 15 + ? ( 16 + <button 17 + type="button" 18 + hx-delete={`/actions/photo/${new AtUri(uri).rkey}`} 19 + class="bg-zinc-950 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center" 20 + _="on htmx:afterOnLoad remove me.parentNode" 21 + > 22 + <i class="fas fa-close text-white"></i> 23 + </button> 24 + ) 25 + : null} 26 + <img 27 + src={src} 28 + alt="" 29 + data-state={uri ? "complete" : "pending"} 30 + class="absolute inset-0 w-full h-full object-contain data-[state=pending]:opacity-50" 31 + /> 32 + </div> 33 + ); 34 + }
+42
src/components/PhotoSelectButton.tsx
··· 1 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 + import { AtUri } from "@atproto/syntax"; 3 + 4 + export function PhotoSelectButton({ 5 + galleryUri, 6 + itemUris, 7 + photo, 8 + }: Readonly<{ 9 + galleryUri: string; 10 + itemUris: string[]; 11 + photo: PhotoView; 12 + }>) { 13 + return ( 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" 19 + 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`} 31 + > 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" /> 34 + </div> 35 + <img 36 + src={photo.fullsize} 37 + alt={photo.alt} 38 + class="absolute inset-0 w-full h-full object-contain" 39 + /> 40 + </button> 41 + ); 42 + }
+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-30"> 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 + }
+62
src/components/ProfileDialog.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Button, Dialog, Input, Textarea } from "@bigmoves/bff/components"; 3 + import { AvatarForm } from "./AvatarForm.tsx"; 4 + 5 + export function ProfileDialog({ 6 + profile, 7 + }: Readonly<{ 8 + profile: ProfileView; 9 + }>) { 10 + return ( 11 + <Dialog> 12 + <Dialog.Content class="dark:bg-zinc-950 relative"> 13 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 14 + <Dialog.Title>Edit my profile</Dialog.Title> 15 + <div> 16 + <AvatarForm src={profile.avatar} alt={profile.handle} /> 17 + </div> 18 + <form 19 + hx-post="/actions/profile/update" 20 + hx-swap="none" 21 + _="on htmx:afterOnLoad trigger closeModal" 22 + > 23 + <div id="image-input" /> 24 + <div class="mb-4 relative"> 25 + <label htmlFor="displayName">Display Name</label> 26 + <Input 27 + type="text" 28 + required 29 + id="displayName" 30 + name="displayName" 31 + class="dark:bg-zinc-800 dark:text-white" 32 + value={profile.displayName} 33 + autoFocus 34 + /> 35 + </div> 36 + <div class="mb-4 relative"> 37 + <label htmlFor="description">Description</label> 38 + <Textarea 39 + id="description" 40 + name="description" 41 + rows={4} 42 + class="dark:bg-zinc-800 dark:text-white" 43 + > 44 + {profile.description} 45 + </Textarea> 46 + </div> 47 + <Button type="submit" variant="primary" class="w-full"> 48 + Update 49 + </Button> 50 + <Button 51 + variant="secondary" 52 + type="button" 53 + class="w-full" 54 + _={Dialog._closeOnClick} 55 + > 56 + Cancel 57 + </Button> 58 + </form> 59 + </Dialog.Content> 60 + </Dialog> 61 + ); 62 + }
+166
src/components/ProfilePage.tsx
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 3 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 4 + import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 5 + import { Un$Typed } from "$lexicon/util.ts"; 6 + import { AtUri } from "@atproto/syntax"; 7 + import { Button, cn } from "@bigmoves/bff/components"; 8 + import { TimelineItem } from "../timeline.ts"; 9 + import { galleryLink, profileLink } from "../utils.ts"; 10 + import { AvatarButton } from "./AvatarButton.tsx"; 11 + import { FollowButton } from "./FollowButton.tsx"; 12 + import { TimelineItem as Item } from "./TimelineItem.tsx"; 13 + 14 + export function ProfilePage({ 15 + followUri, 16 + loggedInUserDid, 17 + timelineItems, 18 + profile, 19 + selectedTab, 20 + galleries, 21 + }: Readonly<{ 22 + followUri?: string; 23 + loggedInUserDid?: string; 24 + timelineItems: TimelineItem[]; 25 + profile: Un$Typed<ProfileView>; 26 + selectedTab?: string; 27 + galleries?: GalleryView[]; 28 + }>) { 29 + const isCreator = loggedInUserDid === profile.did; 30 + const displayName = profile.displayName || profile.handle; 31 + return ( 32 + <div class="px-4 mb-4" id="profile-page"> 33 + <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> 34 + <div class="flex flex-col mb-4"> 35 + <AvatarButton profile={profile} /> 36 + <p class="text-2xl font-bold">{displayName}</p> 37 + <p class="text-zinc-600 dark:text-zinc-500">@{profile.handle}</p> 38 + {profile.description 39 + ? <p class="mt-2">{profile.description}</p> 40 + : null} 41 + </div> 42 + {!isCreator && loggedInUserDid 43 + ? ( 44 + <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 45 + <FollowButton followeeDid={profile.did} followUri={followUri} /> 46 + </div> 47 + ) 48 + : null} 49 + {isCreator 50 + ? ( 51 + <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 52 + <Button variant="primary" class="w-full sm:w-fit" asChild> 53 + <a href="/upload"> 54 + <i class="fa-solid fa-upload mr-2" /> 55 + Upload 56 + </a> 57 + </Button> 58 + <Button 59 + variant="primary" 60 + type="button" 61 + hx-get="/dialogs/profile" 62 + hx-target="#layout" 63 + hx-swap="afterbegin" 64 + class="w-full sm:w-fit" 65 + > 66 + Edit Profile 67 + </Button> 68 + <Button 69 + variant="primary" 70 + type="button" 71 + class="w-full sm:w-fit" 72 + hx-get="/dialogs/gallery/new" 73 + hx-target="#layout" 74 + hx-swap="afterbegin" 75 + > 76 + Create Gallery 77 + </Button> 78 + </div> 79 + ) 80 + : null} 81 + </div> 82 + <div class="my-4 space-x-2 w-full flex sm:w-fit" role="tablist"> 83 + <button 84 + type="button" 85 + hx-get={profileLink(profile.handle)} 86 + hx-target="body" 87 + hx-swap="outerHTML" 88 + class={cn( 89 + "flex-1 py-2 px-4 cursor-pointer font-semibold", 90 + !selectedTab && "bg-zinc-100 dark:bg-zinc-800 font-semibold", 91 + )} 92 + role="tab" 93 + aria-selected="true" 94 + aria-controls="tab-content" 95 + > 96 + Activity 97 + </button> 98 + <button 99 + type="button" 100 + hx-get={profileLink(profile.handle) + "?tab=galleries"} 101 + hx-target="#profile-page" 102 + hx-swap="outerHTML" 103 + class={cn( 104 + "flex-1 py-2 px-4 cursor-pointer font-semibold", 105 + selectedTab === "galleries" && "bg-zinc-100 dark:bg-zinc-800", 106 + )} 107 + role="tab" 108 + aria-selected="false" 109 + aria-controls="tab-content" 110 + > 111 + Galleries 112 + </button> 113 + </div> 114 + <div id="tab-content" role="tabpanel"> 115 + {!selectedTab 116 + ? ( 117 + <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 118 + {timelineItems.length 119 + ? ( 120 + timelineItems.map((item) => ( 121 + <Item item={item} key={item.itemUri} /> 122 + )) 123 + ) 124 + : <li>No activity yet.</li>} 125 + </ul> 126 + ) 127 + : null} 128 + {selectedTab === "galleries" 129 + ? ( 130 + <div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4"> 131 + {galleries?.length 132 + ? ( 133 + galleries.map((gallery) => ( 134 + <a 135 + href={galleryLink( 136 + gallery.creator.handle, 137 + new AtUri(gallery.uri).rkey, 138 + )} 139 + class="cursor-pointer relative aspect-square" 140 + > 141 + {gallery.items?.length 142 + ? ( 143 + <img 144 + src={gallery.items?.filter(isPhotoView)?.[0] 145 + ?.fullsize} 146 + alt={gallery.items?.filter(isPhotoView)?.[0]?.alt} 147 + class="w-full h-full object-cover" 148 + /> 149 + ) 150 + : ( 151 + <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" /> 152 + )} 153 + <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2"> 154 + {(gallery.record as Gallery).title} 155 + </div> 156 + </a> 157 + )) 158 + ) 159 + : <p>No galleries yet.</p>} 160 + </div> 161 + ) 162 + : null} 163 + </div> 164 + </div> 165 + ); 166 + }
+25
src/components/ShareGalleryButton.tsx
··· 1 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 2 + import { Button } from "@bigmoves/bff/components"; 3 + import { publicGalleryLink } from "../utils.ts"; 4 + 5 + export function ShareGalleryButton( 6 + { gallery }: Readonly<{ gallery: GalleryView }>, 7 + ) { 8 + const intentLink = `https://bsky.app/intent/compose?text=${ 9 + encodeURIComponent( 10 + "Check out this gallery on @grain.social \n" + 11 + publicGalleryLink(gallery.creator.handle, gallery.uri), 12 + ) 13 + }`; 14 + return ( 15 + <Button 16 + variant="primary" 17 + asChild 18 + > 19 + <a href={intentLink} target="_blank" rel="noopener noreferrer"> 20 + <i class="fa-solid fa-arrow-up-from-bracket mr-2" /> 21 + Share to Bluesky 22 + </a> 23 + </Button> 24 + ); 25 + }
+41
src/components/TimelineItem.tsx
··· 1 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 2 + import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 + import { AtUri } from "@atproto/syntax"; 4 + import { type TimelineItem } from "../timeline.ts"; 5 + import { formatRelativeTime, galleryLink } from "../utils.ts"; 6 + import { ActorInfo } from "./ActorInfo.tsx"; 7 + import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx"; 8 + 9 + export function TimelineItem({ item }: Readonly<{ item: TimelineItem }>) { 10 + return ( 11 + <li> 12 + <div class="w-fit flex flex-col gap-4 pb-4"> 13 + <div class="flex items-center justify-between gap-2 w-full"> 14 + <ActorInfo profile={item.actor} /> 15 + <span class="shrink-0"> 16 + {formatRelativeTime(new Date(item.createdAt))} 17 + </span> 18 + </div> 19 + {item.gallery.items?.filter(isPhotoView).length 20 + ? ( 21 + <GalleryPreviewLink 22 + gallery={item.gallery} 23 + /> 24 + ) 25 + : null} 26 + <p> 27 + {item.itemType === "favorite" ? "Favorited" : "Created"}{" "} 28 + <a 29 + href={galleryLink( 30 + item.gallery.creator.handle, 31 + new AtUri(item.gallery.uri).rkey, 32 + )} 33 + class="font-semibold hover:underline" 34 + > 35 + {(item.gallery.record as Gallery).title} 36 + </a> 37 + </p> 38 + </div> 39 + </li> 40 + ); 41 + }
+16
src/components/Timline.tsx
··· 1 + import { type TimelineItem } from "../timeline.ts"; 2 + import { Header } from "./Header.tsx"; 3 + import { TimelineItem as Item } from "./TimelineItem.tsx"; 4 + 5 + export function Timeline({ items }: Readonly<{ items: TimelineItem[] }>) { 6 + return ( 7 + <div class="px-4 mb-4"> 8 + <div class="my-4"> 9 + <Header>Timeline</Header> 10 + </div> 11 + <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y w-fit"> 12 + {items.map((item) => <Item item={item} key={item.itemUri} />)} 13 + </ul> 14 + </div> 15 + ); 16 + }
+52
src/components/UploadPage.tsx
··· 1 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 2 + import { Button } from "https://jsr.io/@bigmoves/bff/0.3.0-beta.21/components/Button.tsx"; 3 + import { profileLink } from "../utils.ts"; 4 + import { PhotoPreview } from "./PhotoPreview.tsx"; 5 + 6 + export function UploadPage({ 7 + handle, 8 + photos, 9 + returnTo, 10 + }: Readonly<{ handle: string; photos: PhotoView[]; returnTo?: string }>) { 11 + return ( 12 + <div class="flex flex-col px-4 pt-4 mb-4 space-y-4"> 13 + <div class="flex"> 14 + <div class="flex-1"> 15 + {returnTo 16 + ? ( 17 + <a href={returnTo} class="hover:underline"> 18 + <i class="fa-solid fa-arrow-left mr-2" /> 19 + Back to gallery 20 + </a> 21 + ) 22 + : ( 23 + <a href={profileLink(handle)} class="hover:underline"> 24 + <i class="fa-solid fa-arrow-left mr-2" /> 25 + Back to profile 26 + </a> 27 + )} 28 + </div> 29 + </div> 30 + <Button variant="primary" class="mb-4 w-full sm:w-fit" asChild> 31 + <label> 32 + <i class="fa fa-plus"></i> Add photos 33 + <input 34 + class="hidden" 35 + type="file" 36 + multiple 37 + accept="image/*" 38 + _="on change call uploadPhotos(me)" 39 + /> 40 + </label> 41 + </Button> 42 + <div 43 + id="image-preview" 44 + class="w-full h-full grid grid-cols-2 sm:grid-cols-5 gap-2" 45 + > 46 + {photos.map((photo) => ( 47 + <PhotoPreview key={photo.cid} src={photo.thumb} uri={photo.uri} /> 48 + ))} 49 + </div> 50 + </div> 51 + ); 52 + }
+3
src/env.ts
··· 1 + export const PUBLIC_URL = Deno.env.get("BFF_PUBLIC_URL") ?? 2 + "http://localhost:8080"; 3 + export const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL");
+30
src/errors.ts
··· 1 + import { OAUTH_ROUTES, RateLimitError, UnauthorizedError } from "@bigmoves/bff"; 2 + import { formatDuration, intervalToDuration } from "date-fns"; 3 + 4 + export function onError(err: unknown): Response { 5 + if (err instanceof UnauthorizedError) { 6 + const ctx = err.ctx; 7 + return ctx.redirect(OAUTH_ROUTES.loginPage); 8 + } 9 + if (err instanceof RateLimitError) { 10 + const now = new Date(); 11 + const future = new Date(now.getTime() + (err.retryAfter ?? 0) * 1000); 12 + const duration = intervalToDuration({ start: now, end: future }); 13 + const formatted = formatDuration(duration, { 14 + format: ["minutes", "seconds"], 15 + }); 16 + return new Response( 17 + `Too many requests. Retry in ${formatted}.`, 18 + { 19 + status: 429, 20 + headers: { 21 + ...err.retryAfter && { "Retry-After": err.retryAfter.toString() }, 22 + "Content-Type": "text/plain", 23 + }, 24 + }, 25 + ); 26 + } 27 + return new Response("Internal Server Error", { 28 + status: 500, 29 + }); 30 + }
+27
src/follow.ts
··· 1 + import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 + import { BffContext, WithBffMeta } from "@bigmoves/bff"; 3 + 4 + export function getFollow( 5 + followeeDid: string, 6 + followerDid: string, 7 + ctx: BffContext, 8 + ) { 9 + const { 10 + items: [follow], 11 + } = ctx.indexService.getRecords<WithBffMeta<BskyFollow>>( 12 + "app.bsky.graph.follow", 13 + { 14 + where: [ 15 + { 16 + field: "did", 17 + equals: followerDid, 18 + }, 19 + { 20 + field: "subject", 21 + equals: followeeDid, 22 + }, 23 + ], 24 + }, 25 + ); 26 + return follow; 27 + }
+159
src/gallery.ts
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 4 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 5 + import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 6 + import { 7 + isRecord as isPhoto, 8 + Record as Photo, 9 + } from "$lexicon/types/social/grain/photo.ts"; 10 + import { 11 + isPhotoView, 12 + PhotoView, 13 + } from "$lexicon/types/social/grain/photo/defs.ts"; 14 + import { Un$Typed } from "$lexicon/util.ts"; 15 + import { AtUri } from "@atproto/syntax"; 16 + import { BffContext, WithBffMeta } from "@bigmoves/bff"; 17 + import { getActorProfile } from "./actor.ts"; 18 + import { photoToView } from "./photo.ts"; 19 + 20 + export function getGalleryItemsAndPhotos( 21 + ctx: BffContext, 22 + galleries: WithBffMeta<Gallery>[], 23 + ): Map<string, WithBffMeta<Photo>[]> { 24 + const galleryUris = galleries.map( 25 + (gallery) => 26 + `at://${gallery.did}/social.grain.gallery/${new AtUri(gallery.uri).rkey}`, 27 + ); 28 + 29 + if (galleryUris.length === 0) return new Map(); 30 + 31 + const { items: galleryItems } = ctx.indexService.getRecords< 32 + WithBffMeta<GalleryItem> 33 + >("social.grain.gallery.item", { 34 + orderBy: [{ field: "position", direction: "asc" }], 35 + where: [{ field: "gallery", in: galleryUris }], 36 + }); 37 + 38 + const photoUris = galleryItems.map((item) => item.item).filter(Boolean); 39 + if (photoUris.length === 0) return new Map(); 40 + 41 + const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>( 42 + "social.grain.photo", 43 + { 44 + where: [{ field: "uri", in: photoUris }], 45 + }, 46 + ); 47 + 48 + const photosMap = new Map<string, WithBffMeta<Photo>>(); 49 + for (const photo of photos) { 50 + photosMap.set(photo.uri, photo); 51 + } 52 + 53 + const galleryPhotosMap = new Map<string, WithBffMeta<Photo>[]>(); 54 + for (const item of galleryItems) { 55 + const galleryUri = item.gallery; 56 + const photo = photosMap.get(item.item); 57 + 58 + if (!galleryPhotosMap.has(galleryUri)) { 59 + galleryPhotosMap.set(galleryUri, []); 60 + } 61 + 62 + if (photo) { 63 + galleryPhotosMap.get(galleryUri)?.push(photo); 64 + } 65 + } 66 + 67 + return galleryPhotosMap; 68 + } 69 + 70 + export function getGallery(handleOrDid: string, rkey: string, ctx: BffContext) { 71 + let did: string; 72 + if (handleOrDid.includes("did:")) { 73 + did = handleOrDid; 74 + } else { 75 + const actor = ctx.indexService.getActorByHandle(handleOrDid); 76 + if (!actor) return null; 77 + did = actor.did; 78 + } 79 + const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 80 + `at://${did}/social.grain.gallery/${rkey}`, 81 + ); 82 + if (!gallery) return null; 83 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]); 84 + const profile = getActorProfile(did, ctx); 85 + if (!profile) return null; 86 + return galleryToView( 87 + gallery, 88 + profile, 89 + galleryPhotosMap.get(gallery.uri) ?? [], 90 + ); 91 + } 92 + 93 + export async function deleteGallery(uri: string, ctx: BffContext) { 94 + await ctx.deleteRecord(uri); 95 + const { items: galleryItems } = ctx.indexService.getRecords< 96 + WithBffMeta<GalleryItem> 97 + >("social.grain.gallery.item", { 98 + where: [{ field: "gallery", equals: uri }], 99 + }); 100 + for (const item of galleryItems) { 101 + await ctx.deleteRecord(item.uri); 102 + } 103 + const { items: favs } = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 104 + "social.grain.favorite", 105 + { 106 + where: [{ field: "subject", equals: uri }], 107 + }, 108 + ); 109 + for (const fav of favs) { 110 + await ctx.deleteRecord(fav.uri); 111 + } 112 + } 113 + 114 + export function getGalleryFavs(galleryUri: string, ctx: BffContext) { 115 + const atUri = new AtUri(galleryUri); 116 + const results = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 117 + "social.grain.favorite", 118 + { 119 + where: [ 120 + { 121 + field: "subject", 122 + equals: `at://${atUri.hostname}/social.grain.gallery/${atUri.rkey}`, 123 + }, 124 + ], 125 + }, 126 + ); 127 + return results.items; 128 + } 129 + 130 + export function galleryToView( 131 + record: WithBffMeta<Gallery>, 132 + creator: Un$Typed<ProfileView>, 133 + items: Photo[], 134 + ): Un$Typed<GalleryView> { 135 + return { 136 + uri: record.uri, 137 + cid: record.cid, 138 + creator, 139 + record, 140 + items: items 141 + ?.map((item) => itemToView(record.did, item)) 142 + .filter(isPhotoView), 143 + indexedAt: record.indexedAt, 144 + }; 145 + } 146 + 147 + function itemToView( 148 + did: string, 149 + item: 150 + | WithBffMeta<Photo> 151 + | { 152 + $type: string; 153 + }, 154 + ): Un$Typed<PhotoView> | undefined { 155 + if (isPhoto(item)) { 156 + return photoToView(did, item); 157 + } 158 + return undefined; 159 + }
+96
src/main.tsx
··· 1 + import { lexicons } from "$lexicon/lexicons.ts"; 2 + import { bff, BffContext, JETSTREAM, oauth, route } from "@bigmoves/bff"; 3 + import { Root } from "./app.tsx"; 4 + import { LoginPage } from "./components/LoginPage.tsx"; 5 + import { onError } from "./errors.ts"; 6 + import * as actionHandlers from "./routes/actions.tsx"; 7 + import * as dialogHandlers from "./routes/dialogs.tsx"; 8 + import { handler as galleryHandler } from "./routes/gallery.tsx"; 9 + import { handler as galleryEmbedHandler } from "./routes/gallery_embed.tsx"; 10 + import { handler as notificationsHandler } from "./routes/notifications.tsx"; 11 + import { handler as onboardHandler } from "./routes/onboard.tsx"; 12 + import { handler as profileHandler } from "./routes/profile.tsx"; 13 + import { handler as timelineHandler } from "./routes/timeline.tsx"; 14 + import { handler as uploadHandler } from "./routes/upload.tsx"; 15 + import { appStateMiddleware, type State } from "./state.ts"; 16 + import { avatarUploadRoutes, photoUploadRoutes } from "./uploads.tsx"; 17 + import { generateStaticFilesHash, onSignedIn } from "./utils.ts"; 18 + 19 + let staticFilesHash = new Map<string, string>(); 20 + 21 + bff({ 22 + appName: "Grain Social", 23 + collections: [ 24 + "social.grain.gallery", 25 + "social.grain.actor.profile", 26 + "social.grain.photo", 27 + "social.grain.favorite", 28 + "social.grain.gallery.item", 29 + ], 30 + jetstreamUrl: JETSTREAM.WEST_1, 31 + lexicons, 32 + rootElement: Root, 33 + onListen: async () => { 34 + staticFilesHash = await generateStaticFilesHash(); 35 + }, 36 + onError, 37 + middlewares: [ 38 + (_req, ctx: BffContext<State>) => { 39 + ctx.state.staticFilesHash = staticFilesHash; 40 + return ctx.next(); 41 + }, 42 + appStateMiddleware, 43 + oauth({ 44 + onSignedIn, 45 + LoginComponent: LoginPage, 46 + }), 47 + route("/", timelineHandler), 48 + route("/notifications", notificationsHandler), 49 + route("/profile/:handle", profileHandler), 50 + route("/profile/:handle/gallery/:rkey", galleryHandler), 51 + route("/embed/profile/:did/gallery/:rkey", galleryEmbedHandler), 52 + route("/upload", uploadHandler), 53 + route("/onboard", onboardHandler), 54 + route("/dialogs/gallery/new", dialogHandlers.createGallery), 55 + route("/dialogs/gallery/:rkey", dialogHandlers.editGallery), 56 + route("/dialogs/gallery/:rkey/sort", dialogHandlers.sortGallery), 57 + route("/dialogs/profile", dialogHandlers.editProfile), 58 + route("/dialogs/avatar/:handle", dialogHandlers.avatar), 59 + route("/dialogs/image", dialogHandlers.image), 60 + route("/dialogs/photo/:rkey/alt", dialogHandlers.photoAlt), 61 + route( 62 + "/dialogs/photo-select/:galleryRkey", 63 + dialogHandlers.galleryPhotoSelect, 64 + ), 65 + route("/actions/update-seen", ["POST"], actionHandlers.updateSeen), 66 + route("/actions/follow/:did", ["POST"], actionHandlers.follow), 67 + route( 68 + "/actions/follow/:followeeDid/:rkey", 69 + ["DELETE"], 70 + actionHandlers.unfollow, 71 + ), 72 + route("/actions/create-edit", ["POST"], actionHandlers.galleryCreateEdit), 73 + route("/actions/gallery/delete", ["POST"], actionHandlers.galleryDelete), 74 + route( 75 + "/actions/gallery/:galleryRkey/add-photo/:photoRkey", 76 + ["PUT"], 77 + actionHandlers.galleryAddPhoto, 78 + ), 79 + route( 80 + "/actions/gallery/:galleryRkey/remove-photo/:photoRkey", 81 + ["PUT"], 82 + actionHandlers.galleryRemovePhoto, 83 + ), 84 + route("/actions/photo/:rkey", ["PUT"], actionHandlers.photoEdit), 85 + route("/actions/photo/:rkey", ["DELETE"], actionHandlers.photoDelete), 86 + route("/actions/favorite", ["POST"], actionHandlers.galleryFavorite), 87 + route("/actions/profile/update", ["POST"], actionHandlers.profileUpdate), 88 + route( 89 + "/actions/gallery/:rkey/sort", 90 + ["POST"], 91 + actionHandlers.gallerySort, 92 + ), 93 + ...photoUploadRoutes(), 94 + ...avatarUploadRoutes(), 95 + ], 96 + });
+42
src/meta.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 { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 4 + import { AtUri } from "@atproto/syntax"; 5 + import { MetaDescriptor } from "@bigmoves/bff/components"; 6 + import { PUBLIC_URL } from "./env.ts"; 7 + import { galleryLink } from "./utils.ts"; 8 + 9 + export function getPageMeta(pageUrl: string): MetaDescriptor[] { 10 + return [ 11 + { 12 + tagName: "link", 13 + property: "canonical", 14 + href: `${PUBLIC_URL}${pageUrl}`, 15 + }, 16 + { property: "og:site_name", content: "Grain Social" }, 17 + ]; 18 + } 19 + 20 + export function getGalleryMeta(gallery: GalleryView): MetaDescriptor[] { 21 + return [ 22 + // { property: "og:type", content: "website" }, 23 + { 24 + property: "og:url", 25 + content: `${PUBLIC_URL}${ 26 + galleryLink( 27 + gallery.creator.handle, 28 + new AtUri(gallery.uri).rkey, 29 + ) 30 + }`, 31 + }, 32 + { property: "og:title", content: (gallery.record as Gallery).title }, 33 + { 34 + property: "og:description", 35 + content: (gallery.record as Gallery).description, 36 + }, 37 + { 38 + property: "og:image", 39 + content: gallery?.items?.filter(isPhotoView)?.[0]?.thumb, 40 + }, 41 + ]; 42 + }
+50
src/notifications.ts
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 + import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 4 + import { Un$Typed } from "$lexicon/util.ts"; 5 + import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff"; 6 + import { getActorProfile } from "./actor.ts"; 7 + 8 + export type NotificationRecords = WithBffMeta<Favorite>; 9 + 10 + export function getNotifications( 11 + currentUser: ActorTable, 12 + ctx: BffContext, 13 + ) { 14 + const { lastSeenNotifs } = currentUser; 15 + const notifications = ctx.getNotifications<NotificationRecords>(); 16 + return notifications.map((notification) => { 17 + const actor = ctx.indexService.getActor(notification.did); 18 + const authorProfile = getActorProfile(notification.did, ctx); 19 + if (!actor || !authorProfile) return null; 20 + return notificationToView( 21 + notification, 22 + authorProfile, 23 + lastSeenNotifs, 24 + ); 25 + }).filter((view): view is Un$Typed<NotificationView> => Boolean(view)); 26 + } 27 + 28 + export function notificationToView( 29 + record: NotificationRecords, 30 + author: Un$Typed<ProfileView>, 31 + lastSeenNotifs: string | undefined, 32 + ): Un$Typed<NotificationView> { 33 + const reason = record.$type === "social.grain.favorite" 34 + ? "gallery-favorite" 35 + : "unknown"; 36 + const reasonSubject = record.$type === "social.grain.favorite" 37 + ? record.subject 38 + : undefined; 39 + const isRead = lastSeenNotifs ? record.createdAt <= lastSeenNotifs : false; 40 + return { 41 + uri: record.uri, 42 + cid: record.cid, 43 + author, 44 + record, 45 + reason, 46 + reasonSubject, 47 + isRead, 48 + indexedAt: record.indexedAt, 49 + }; 50 + }
+25
src/photo.ts
··· 1 + import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 2 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 3 + import { $Typed } from "$lexicon/util.ts"; 4 + import { WithBffMeta } from "@bigmoves/bff"; 5 + 6 + export function photoThumb(did: string, cid: string) { 7 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`; 8 + } 9 + 10 + export function photoToView( 11 + did: string, 12 + photo: WithBffMeta<Photo>, 13 + ): $Typed<PhotoView> { 14 + return { 15 + $type: "social.grain.photo.defs#photoView", 16 + uri: photo.uri, 17 + cid: photo.photo.ref.toString(), 18 + thumb: 19 + `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${photo.photo.ref.toString()}@jpeg`, 20 + fullsize: 21 + `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${photo.photo.ref.toString()}@jpeg`, 22 + alt: photo.alt, 23 + aspectRatio: photo.aspectRatio, 24 + }; 25 + }
+359
src/routes/actions.tsx
··· 1 + import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 + import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 4 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 5 + import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 6 + import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 7 + import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 8 + import { AtUri } from "@atproto/syntax"; 9 + import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 10 + import { FavoriteButton } from "../components/FavoriteButton.tsx"; 11 + import { FollowButton } from "../components/FollowButton.tsx"; 12 + import { PhotoButton } from "../components/PhotoButton.tsx"; 13 + import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx"; 14 + import { deleteGallery, getGallery, getGalleryFavs } from "../gallery.ts"; 15 + import { photoToView } from "../photo.ts"; 16 + import type { State } from "../state.ts"; 17 + import { photoProcessor } from "../uploads.tsx"; 18 + import { galleryLink } from "../utils.ts"; 19 + 20 + export const updateSeen: RouteHandler = ( 21 + _req, 22 + _params, 23 + ctx: BffContext<State>, 24 + ) => { 25 + ctx.requireAuth(); 26 + ctx.updateSeen(); 27 + return new Response(null, { status: 200 }); 28 + }; 29 + 30 + export const follow: RouteHandler = async ( 31 + _req, 32 + params, 33 + ctx: BffContext<State>, 34 + ) => { 35 + ctx.requireAuth(); 36 + const did = params.did; 37 + if (!did) return ctx.next(); 38 + const followUri = await ctx.createRecord<BskyFollow>( 39 + "app.bsky.graph.follow", 40 + { 41 + subject: did, 42 + createdAt: new Date().toISOString(), 43 + }, 44 + ); 45 + return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 46 + }; 47 + 48 + export const unfollow: RouteHandler = async ( 49 + _req, 50 + params, 51 + ctx: BffContext<State>, 52 + ) => { 53 + const { did } = ctx.requireAuth(); 54 + const followeeDid = params.followeeDid; 55 + const rkey = params.rkey; 56 + await ctx.deleteRecord( 57 + `at://${did}/app.bsky.graph.follow/${rkey}`, 58 + ); 59 + return ctx.html( 60 + <FollowButton followeeDid={followeeDid} followUri={undefined} />, 61 + ); 62 + }; 63 + 64 + export const galleryCreateEdit: RouteHandler = async ( 65 + req, 66 + _params, 67 + ctx: BffContext<State>, 68 + ) => { 69 + const { handle } = ctx.requireAuth(); 70 + const formData = await req.formData(); 71 + const title = formData.get("title") as string; 72 + const description = formData.get("description") as string; 73 + const url = new URL(req.url); 74 + const searchParams = new URLSearchParams(url.search); 75 + const uri = searchParams.get("uri"); 76 + 77 + if (uri) { 78 + const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(uri); 79 + if (!gallery) return ctx.next(); 80 + const rkey = new AtUri(uri).rkey; 81 + try { 82 + await ctx.updateRecord<Gallery>("social.grain.gallery", rkey, { 83 + title, 84 + description, 85 + createdAt: gallery.createdAt, 86 + }); 87 + } catch (e) { 88 + console.error("Error updating record:", e); 89 + const errorMessage = e instanceof Error 90 + ? e.message 91 + : "Unknown error occurred"; 92 + return new Response(errorMessage, { status: 400 }); 93 + } 94 + return ctx.redirect(galleryLink(handle, rkey)); 95 + } 96 + 97 + const createdUri = await ctx.createRecord<Gallery>( 98 + "social.grain.gallery", 99 + { 100 + title, 101 + description, 102 + createdAt: new Date().toISOString(), 103 + }, 104 + ); 105 + return ctx.redirect(galleryLink(handle, new AtUri(createdUri).rkey)); 106 + }; 107 + 108 + export const galleryDelete: RouteHandler = async ( 109 + req, 110 + _params, 111 + ctx: BffContext<State>, 112 + ) => { 113 + ctx.requireAuth(); 114 + const formData = await req.formData(); 115 + const uri = formData.get("uri") as string; 116 + await deleteGallery(uri, ctx); 117 + return ctx.redirect("/"); 118 + }; 119 + 120 + export const galleryAddPhoto: RouteHandler = async ( 121 + _req, 122 + params, 123 + ctx: BffContext<State>, 124 + ) => { 125 + const { did } = ctx.requireAuth(); 126 + const galleryRkey = params.galleryRkey; 127 + const photoRkey = params.photoRkey; 128 + const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; 129 + const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 130 + const gallery = getGallery(did, galleryRkey, ctx); 131 + const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 132 + if (!gallery || !photo) return ctx.next(); 133 + if ( 134 + gallery.items 135 + ?.filter(isPhotoView) 136 + .some((item) => item.uri === photoUri) 137 + ) { 138 + return new Response(null, { status: 500 }); 139 + } 140 + await ctx.createRecord<Gallery>("social.grain.gallery.item", { 141 + gallery: galleryUri, 142 + item: photoUri, 143 + position: gallery.items?.length ?? 0, 144 + createdAt: new Date().toISOString(), 145 + }); 146 + gallery.items = [ 147 + ...(gallery.items ?? []), 148 + photoToView(photo.did, photo), 149 + ]; 150 + return ctx.html( 151 + <> 152 + <div hx-swap-oob="beforeend:#masonry-container"> 153 + <PhotoButton 154 + key={photo.cid} 155 + photo={photoToView(photo.did, photo)} 156 + gallery={gallery} 157 + /> 158 + </div> 159 + <PhotoSelectButton 160 + galleryUri={galleryUri} 161 + itemUris={gallery.items?.filter(isPhotoView).map((item) => item.uri) ?? 162 + []} 163 + photo={photoToView(photo.did, photo)} 164 + /> 165 + </>, 166 + ); 167 + }; 168 + 169 + export const galleryRemovePhoto: RouteHandler = async ( 170 + _req, 171 + params, 172 + ctx: BffContext<State>, 173 + ) => { 174 + const { did } = ctx.requireAuth(); 175 + const galleryRkey = params.galleryRkey; 176 + const photoRkey = params.photoRkey; 177 + const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; 178 + const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 179 + if (!galleryRkey || !photoRkey) return ctx.next(); 180 + const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 181 + if (!photo) return ctx.next(); 182 + const { 183 + items: [item], 184 + } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 185 + "social.grain.gallery.item", 186 + { 187 + where: [ 188 + { 189 + field: "gallery", 190 + equals: galleryUri, 191 + }, 192 + { 193 + field: "item", 194 + equals: photoUri, 195 + }, 196 + ], 197 + }, 198 + ); 199 + if (!item) return ctx.next(); 200 + await ctx.deleteRecord(item.uri); 201 + const gallery = getGallery(did, galleryRkey, ctx); 202 + if (!gallery) return ctx.next(); 203 + return ctx.html( 204 + <PhotoSelectButton 205 + galleryUri={galleryUri} 206 + itemUris={gallery.items?.filter(isPhotoView).map((item) => item.uri) ?? 207 + []} 208 + photo={photoToView(photo.did, photo)} 209 + />, 210 + ); 211 + }; 212 + 213 + export const photoEdit: RouteHandler = async ( 214 + req, 215 + params, 216 + ctx: BffContext<State>, 217 + ) => { 218 + const { did } = ctx.requireAuth(); 219 + const photoRkey = params.rkey; 220 + const formData = await req.formData(); 221 + const alt = formData.get("alt") as string; 222 + const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 223 + const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 224 + if (!photo) return ctx.next(); 225 + await ctx.updateRecord<Photo>("social.grain.photo", photoRkey, { 226 + photo: photo.photo, 227 + aspectRatio: photo.aspectRatio, 228 + alt, 229 + createdAt: photo.createdAt, 230 + }); 231 + return new Response(null, { status: 200 }); 232 + }; 233 + 234 + export const photoDelete: RouteHandler = ( 235 + _req, 236 + params, 237 + ctx: BffContext<State>, 238 + ) => { 239 + const { did } = ctx.requireAuth(); 240 + ctx.deleteRecord( 241 + `at://${did}/social.grain.photo/${params.rkey}`, 242 + ); 243 + return new Response(null, { status: 200 }); 244 + }; 245 + 246 + export const galleryFavorite: RouteHandler = async ( 247 + req, 248 + _params, 249 + ctx: BffContext<State>, 250 + ) => { 251 + const { did } = ctx.requireAuth(); 252 + const url = new URL(req.url); 253 + const searchParams = new URLSearchParams(url.search); 254 + const galleryUri = searchParams.get("galleryUri"); 255 + const favUri = searchParams.get("favUri") ?? undefined; 256 + if (!galleryUri) return ctx.next(); 257 + if (favUri) { 258 + await ctx.deleteRecord(favUri); 259 + const favs = getGalleryFavs(galleryUri, ctx); 260 + return ctx.html( 261 + <FavoriteButton 262 + currentUserDid={did} 263 + favs={favs} 264 + galleryUri={galleryUri} 265 + />, 266 + ); 267 + } 268 + await ctx.createRecord<WithBffMeta<Favorite>>("social.grain.favorite", { 269 + subject: galleryUri, 270 + createdAt: new Date().toISOString(), 271 + }); 272 + const favs = getGalleryFavs(galleryUri, ctx); 273 + return ctx.html( 274 + <FavoriteButton 275 + currentUserDid={did} 276 + galleryUri={galleryUri} 277 + favs={favs} 278 + />, 279 + ); 280 + }; 281 + 282 + export const gallerySort: RouteHandler = async ( 283 + req, 284 + params, 285 + ctx: BffContext<State>, 286 + ) => { 287 + const { did, handle } = ctx.requireAuth(); 288 + const galleryRkey = params.rkey; 289 + const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`; 290 + const { 291 + items, 292 + } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 293 + "social.grain.gallery.item", 294 + { 295 + where: [ 296 + { 297 + field: "gallery", 298 + equals: galleryUri, 299 + }, 300 + ], 301 + }, 302 + ); 303 + const itemsMap = new Map<string, WithBffMeta<GalleryItem>>(); 304 + for (const item of items) { 305 + itemsMap.set(item.item, item); 306 + } 307 + const formData = await req.formData(); 308 + const sortedItems = formData.getAll("item") as string[]; 309 + const updates = []; 310 + let position = 0; 311 + for (const sortedItemUri of sortedItems) { 312 + const item = itemsMap.get(sortedItemUri); 313 + if (!item) continue; 314 + updates.push({ 315 + collection: "social.grain.gallery.item", 316 + rkey: new AtUri(item.uri).rkey, 317 + data: { 318 + gallery: item.gallery, 319 + item: item.item, 320 + createdAt: item.createdAt, 321 + position, 322 + }, 323 + }); 324 + position++; 325 + } 326 + await ctx.updateRecords<WithBffMeta<GalleryItem>>(updates); 327 + return ctx.redirect( 328 + galleryLink(handle, new AtUri(galleryUri).rkey), 329 + ); 330 + }; 331 + 332 + export const profileUpdate: RouteHandler = async ( 333 + req, 334 + _params, 335 + ctx: BffContext<State>, 336 + ) => { 337 + const { did, handle } = ctx.requireAuth(); 338 + const formData = await req.formData(); 339 + const displayName = formData.get("displayName") as string; 340 + const description = formData.get("description") as string; 341 + const uploadId = formData.get("uploadId") as string; 342 + 343 + const record = ctx.indexService.getRecord<Profile>( 344 + `at://${did}/social.grain.actor.profile/self`, 345 + ); 346 + 347 + if (!record) { 348 + return new Response("Profile record not found", { status: 404 }); 349 + } 350 + 351 + await ctx.updateRecord<Profile>("social.grain.actor.profile", "self", { 352 + displayName, 353 + description, 354 + avatar: photoProcessor.getUploadStatus(uploadId)?.blobRef ?? 355 + record.avatar, 356 + }); 357 + 358 + return ctx.redirect(`/profile/${handle}`); 359 + };
+155
src/routes/dialogs.tsx
··· 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 + import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 4 + import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 7 + import { wrap } from "popmotion"; 8 + import { getActorPhotos, getActorProfile } from "../actor.ts"; 9 + import { AvatarDialog } from "../components/AvatarDialog.tsx"; 10 + import { GalleryCreateEditDialog } from "../components/GalleryCreateEditDialog.tsx"; 11 + import { GallerySortDialog } from "../components/GallerySortDialog.tsx"; 12 + import { PhotoAltDialog } from "../components/PhotoAltDialog.tsx"; 13 + import { PhotoDialog } from "../components/PhotoDialog.tsx"; 14 + import { PhotoSelectDialog } from "../components/PhotoSelectDialog.tsx"; 15 + import { ProfileDialog } from "../components/ProfileDialog.tsx"; 16 + import { getGallery, getGalleryItemsAndPhotos } from "../gallery.ts"; 17 + import { photoToView } from "../photo.ts"; 18 + import type { State } from "../state.ts"; 19 + 20 + export const createGallery: RouteHandler = ( 21 + _req, 22 + _params, 23 + ctx: BffContext<State>, 24 + ) => { 25 + ctx.requireAuth(); 26 + return ctx.html(<GalleryCreateEditDialog />); 27 + }; 28 + 29 + export const editGallery: RouteHandler = ( 30 + _req, 31 + params, 32 + ctx: BffContext<State>, 33 + ) => { 34 + const { handle } = ctx.requireAuth(); 35 + const rkey = params.rkey; 36 + const gallery = getGallery(handle, rkey, ctx); 37 + return ctx.html(<GalleryCreateEditDialog gallery={gallery} />); 38 + }; 39 + 40 + export const sortGallery: RouteHandler = ( 41 + _req, 42 + params, 43 + ctx: BffContext<State>, 44 + ) => { 45 + const { handle } = ctx.requireAuth(); 46 + const rkey = params.rkey; 47 + const gallery = getGallery(handle, rkey, ctx); 48 + if (!gallery) return ctx.next(); 49 + return ctx.html(<GallerySortDialog gallery={gallery} />); 50 + }; 51 + 52 + export const editProfile: RouteHandler = ( 53 + _req, 54 + _params, 55 + ctx: BffContext<State>, 56 + ) => { 57 + const { did } = ctx.requireAuth(); 58 + if (!ctx.state.profile) return ctx.next(); 59 + const profileRecord = ctx.indexService.getRecord<Profile>( 60 + `at://${did}/social.grain.actor.profile/self`, 61 + ); 62 + if (!profileRecord) return ctx.next(); 63 + return ctx.html( 64 + <ProfileDialog 65 + profile={ctx.state.profile} 66 + />, 67 + ); 68 + }; 69 + 70 + export const avatar: RouteHandler = ( 71 + _req, 72 + params, 73 + ctx: BffContext<State>, 74 + ) => { 75 + const handle = params.handle; 76 + const actor = ctx.indexService.getActorByHandle(handle); 77 + if (!actor) return ctx.next(); 78 + const profile = getActorProfile(actor.did, ctx); 79 + if (!profile) return ctx.next(); 80 + return ctx.html(<AvatarDialog profile={profile} />); 81 + }; 82 + 83 + export const image: RouteHandler = ( 84 + req, 85 + _params, 86 + ctx: BffContext<State>, 87 + ) => { 88 + const url = new URL(req.url); 89 + const galleryUri = url.searchParams.get("galleryUri"); 90 + const imageCid = url.searchParams.get("imageCid"); 91 + if (!galleryUri || !imageCid) return ctx.next(); 92 + const atUri = new AtUri(galleryUri); 93 + const galleryDid = atUri.hostname; 94 + const galleryRkey = atUri.rkey; 95 + const gallery = getGallery(galleryDid, galleryRkey, ctx); 96 + if (!gallery?.items) return ctx.next(); 97 + const image = gallery.items.filter(isPhotoView).find((item) => { 98 + return item.cid === imageCid; 99 + }); 100 + const imageAtIndex = gallery.items 101 + .filter(isPhotoView) 102 + .findIndex((image) => { 103 + return image.cid === imageCid; 104 + }); 105 + const next = wrap(0, gallery.items.length, imageAtIndex + 1); 106 + const prev = wrap(0, gallery.items.length, imageAtIndex - 1); 107 + if (!image) return ctx.next(); 108 + return ctx.html( 109 + <PhotoDialog 110 + gallery={gallery} 111 + image={image} 112 + nextImage={gallery.items.filter(isPhotoView).at(next)} 113 + prevImage={gallery.items.filter(isPhotoView).at(prev)} 114 + />, 115 + ); 116 + }; 117 + 118 + export const photoAlt: RouteHandler = ( 119 + _req, 120 + params, 121 + ctx: BffContext<State>, 122 + ) => { 123 + const { did } = ctx.requireAuth(); 124 + const photoRkey = params.rkey; 125 + const photoUri = `at://${did}/social.grain.photo/${photoRkey}`; 126 + const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 127 + if (!photo) return ctx.next(); 128 + return ctx.html( 129 + <PhotoAltDialog photo={photoToView(did, photo)} />, 130 + ); 131 + }; 132 + 133 + export const galleryPhotoSelect: RouteHandler = ( 134 + _req, 135 + params, 136 + ctx: BffContext<State>, 137 + ) => { 138 + const { did } = ctx.requireAuth(); 139 + const photos = getActorPhotos(did, ctx); 140 + const galleryUri = `at://${did}/social.grain.gallery/${params.galleryRkey}`; 141 + const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 142 + galleryUri, 143 + ); 144 + if (!gallery) return ctx.next(); 145 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]); 146 + const itemUris = 147 + galleryPhotosMap.get(galleryUri)?.map((photo) => photo.uri) ?? []; 148 + return ctx.html( 149 + <PhotoSelectDialog 150 + galleryUri={galleryUri} 151 + itemUris={itemUris} 152 + photos={photos} 153 + />, 154 + ); 155 + };
+31
src/routes/gallery.tsx
··· 1 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 2 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 3 + import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 4 + import { GalleryPage } from "../components/GalleryPage.tsx"; 5 + import { getGallery, getGalleryFavs } from "../gallery.ts"; 6 + import { getGalleryMeta, getPageMeta } from "../meta.ts"; 7 + import type { State } from "../state.ts"; 8 + import { galleryLink } from "../utils.ts"; 9 + 10 + export const handler: RouteHandler = ( 11 + _req, 12 + params, 13 + ctx: BffContext<State>, 14 + ) => { 15 + const did = ctx.currentUser?.did; 16 + let favs: WithBffMeta<Favorite>[] = []; 17 + const handle = params.handle; 18 + const rkey = params.rkey; 19 + const gallery = getGallery(handle, rkey, ctx); 20 + if (!gallery) return ctx.next(); 21 + favs = getGalleryFavs(gallery.uri, ctx); 22 + ctx.state.meta = [ 23 + { title: `${(gallery.record as Gallery).title} — Grain` }, 24 + ...getPageMeta(galleryLink(handle, rkey)), 25 + ...getGalleryMeta(gallery), 26 + ]; 27 + ctx.state.scripts = ["photo_dialog.js", "masonry.js", "sortable.js"]; 28 + return ctx.render( 29 + <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, 30 + ); 31 + };
+14
src/routes/gallery_embed.tsx
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 + import { GalleryPreviewLink } from "../components/GalleryPreviewLink.tsx"; 3 + import { getGallery } from "../gallery.ts"; 4 + import type { State } from "../state.ts"; 5 + 6 + export const handler: RouteHandler = ( 7 + _req, 8 + params, 9 + ctx: BffContext<State>, 10 + ) => { 11 + const gallery = getGallery(params.did, params.rkey, ctx); 12 + if (!gallery) return ctx.next(); 13 + return ctx.html(<GalleryPreviewLink gallery={gallery} size="small" />); 14 + };
+17
src/routes/notifications.tsx
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 + import { NotificationsPage } from "../components/NotificationsPage.tsx"; 3 + import type { State } from "../state.ts"; 4 + 5 + export const handler: RouteHandler = ( 6 + _req, 7 + _params, 8 + ctx: BffContext<State>, 9 + ) => { 10 + ctx.requireAuth(); 11 + ctx.state.meta = [ 12 + { title: "Notifications — Grain" }, 13 + ]; 14 + return ctx.render( 15 + <NotificationsPage notifications={ctx.state.notifications ?? []} />, 16 + ); 17 + };
+18
src/routes/onboard.tsx
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 + import type { State } from "../state.ts"; 3 + 4 + export const handler: RouteHandler = ( 5 + _req, 6 + _params, 7 + ctx: BffContext<State>, 8 + ) => { 9 + ctx.requireAuth(); 10 + return ctx.render( 11 + <div 12 + hx-get="/dialogs/profile" 13 + hx-trigger="load" 14 + hx-target="body" 15 + hx-swap="afterbegin" 16 + />, 17 + ); 18 + };
+57
src/routes/profile.tsx
··· 1 + import { Record as BskyFollow } from "$lexicon/types/app/bsky/graph/follow.ts"; 2 + import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 3 + import { getActorGalleries, getActorProfile } from "../actor.ts"; 4 + import { ProfilePage } from "../components/ProfilePage.tsx"; 5 + import { getFollow } from "../follow.ts"; 6 + import { getPageMeta } from "../meta.ts"; 7 + import type { State } from "../state.ts"; 8 + import { getActorTimeline } from "../timeline.ts"; 9 + import { profileLink } from "../utils.ts"; 10 + 11 + export const handler: RouteHandler = ( 12 + req, 13 + params, 14 + ctx: BffContext<State>, 15 + ) => { 16 + const url = new URL(req.url); 17 + const tab = url.searchParams.get("tab"); 18 + const handle = params.handle; 19 + const timelineItems = getActorTimeline(handle, ctx); 20 + const galleries = getActorGalleries(handle, ctx); 21 + const actor = ctx.indexService.getActorByHandle(handle); 22 + if (!actor) return ctx.next(); 23 + const profile = getActorProfile(actor.did, ctx); 24 + if (!profile) return ctx.next(); 25 + let follow: WithBffMeta<BskyFollow> | undefined; 26 + if (ctx.currentUser) { 27 + follow = getFollow(profile.did, ctx.currentUser.did, ctx); 28 + } 29 + ctx.state.meta = [ 30 + { 31 + title: profile.displayName 32 + ? `${profile.displayName} (${profile.handle}) — Grain` 33 + : `${profile.handle} — Grain`, 34 + }, 35 + ...getPageMeta(profileLink(handle)), 36 + ]; 37 + if (tab) { 38 + return ctx.html( 39 + <ProfilePage 40 + followUri={follow?.uri} 41 + loggedInUserDid={ctx.currentUser?.did} 42 + timelineItems={timelineItems} 43 + profile={profile} 44 + selectedTab={tab} 45 + galleries={galleries} 46 + />, 47 + ); 48 + } 49 + return ctx.render( 50 + <ProfilePage 51 + followUri={follow?.uri} 52 + loggedInUserDid={ctx.currentUser?.did} 53 + timelineItems={timelineItems} 54 + profile={profile} 55 + />, 56 + ); 57 + };
+15
src/routes/timeline.tsx
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 + import { Timeline } from "../components/Timline.tsx"; 3 + import { getPageMeta } from "../meta.ts"; 4 + import type { State } from "../state.ts"; 5 + import { getTimeline } from "../timeline.ts"; 6 + 7 + export const handler: RouteHandler = ( 8 + _req, 9 + _params, 10 + ctx: BffContext<State>, 11 + ) => { 12 + const items = getTimeline(ctx); 13 + ctx.state.meta = [{ title: "Timeline — Grain" }, ...getPageMeta("")]; 14 + return ctx.render(<Timeline items={items} />); 15 + };
+26
src/routes/upload.tsx
··· 1 + import { BffContext, RouteHandler } from "@bigmoves/bff"; 2 + import { getActorPhotos } from "../actor.ts"; 3 + import { UploadPage } from "../components/UploadPage.tsx"; 4 + import { getPageMeta } from "../meta.ts"; 5 + import type { State } from "../state.ts"; 6 + import { galleryLink } from "../utils.ts"; 7 + 8 + export const handler: RouteHandler = ( 9 + req, 10 + _params, 11 + ctx: BffContext<State>, 12 + ) => { 13 + const { did, handle } = ctx.requireAuth(); 14 + const url = new URL(req.url); 15 + const galleryRkey = url.searchParams.get("returnTo"); 16 + const photos = getActorPhotos(did, ctx); 17 + ctx.state.meta = [{ title: "Upload — Grain" }, ...getPageMeta("/upload")]; 18 + ctx.state.scripts = ["upload_page.js"]; 19 + return ctx.render( 20 + <UploadPage 21 + handle={handle} 22 + photos={photos} 23 + returnTo={galleryRkey ? galleryLink(handle, galleryRkey) : undefined} 24 + />, 25 + ); 26 + };
+37
src/state.ts
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 3 + import { Un$Typed } from "$lexicon/util.ts"; 4 + import { BffMiddleware } from "@bigmoves/bff"; 5 + import { MetaDescriptor } from "@bigmoves/bff/components"; 6 + import { getActorProfile } from "./actor.ts"; 7 + import { getNotifications } from "./notifications.ts"; 8 + 9 + export type State = { 10 + profile?: ProfileView; 11 + scripts?: string[]; 12 + meta?: MetaDescriptor[]; 13 + notifications?: Un$Typed<NotificationView>[]; 14 + staticFilesHash?: Map<string, string>; 15 + }; 16 + 17 + export const appStateMiddleware: BffMiddleware = (req, ctx) => { 18 + if (ctx.currentUser) { 19 + const url = new URL(req.url); 20 + // ignore routes prefixed with actions, embed and dialogs (no need to resolve profile) 21 + if ( 22 + ["actions", "embed"].some((path) => url.pathname.includes(path)) || 23 + (url.pathname.includes("dialogs") && 24 + !url.pathname.includes("/dialogs/profile")) 25 + ) { 26 + return ctx.next(); 27 + } 28 + const profile = getActorProfile(ctx.currentUser.did, ctx); 29 + if (profile) { 30 + ctx.state.profile = profile; 31 + } 32 + const notifications = getNotifications(ctx.currentUser, ctx); 33 + ctx.state.notifications = notifications; 34 + return ctx.next(); 35 + } 36 + return ctx.next(); 37 + };
+187
src/timeline.ts
··· 1 + import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 4 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 5 + import { Un$Typed } from "$lexicon/util.ts"; 6 + import { AtUri } from "@atproto/syntax"; 7 + import { BffContext, WithBffMeta } from "@bigmoves/bff"; 8 + import { getActorProfile } from "./actor.ts"; 9 + import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts"; 10 + 11 + type TimelineItemType = "gallery" | "favorite"; 12 + 13 + export type TimelineItem = { 14 + createdAt: string; 15 + itemType: TimelineItemType; 16 + itemUri: string; 17 + actor: Un$Typed<ProfileView>; 18 + gallery: GalleryView; 19 + }; 20 + 21 + type TimelineOptions = { 22 + actorDid?: string; 23 + }; 24 + 25 + function processGalleries( 26 + ctx: BffContext, 27 + options?: TimelineOptions, 28 + ): TimelineItem[] { 29 + const items: TimelineItem[] = []; 30 + 31 + const whereClause = options?.actorDid 32 + ? [{ field: "did", equals: options.actorDid }] 33 + : undefined; 34 + 35 + const { items: galleries } = ctx.indexService.getRecords< 36 + WithBffMeta<Gallery> 37 + >("social.grain.gallery", { 38 + orderBy: [{ field: "createdAt", direction: "desc" }], 39 + where: whereClause, 40 + }); 41 + 42 + if (galleries.length === 0) return items; 43 + 44 + // Get photos for all galleries 45 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 46 + 47 + for (const gallery of galleries) { 48 + const actor = ctx.indexService.getActor(gallery.did); 49 + if (!actor) continue; 50 + const profile = getActorProfile(actor.did, ctx); 51 + if (!profile) continue; 52 + 53 + const galleryUri = `at://${gallery.did}/social.grain.gallery/${ 54 + new AtUri(gallery.uri).rkey 55 + }`; 56 + const galleryPhotos = galleryPhotosMap.get(galleryUri) || []; 57 + 58 + const galleryView = galleryToView(gallery, profile, galleryPhotos); 59 + items.push({ 60 + itemType: "gallery", 61 + createdAt: gallery.createdAt, 62 + itemUri: galleryView.uri, 63 + actor: galleryView.creator, 64 + gallery: galleryView, 65 + }); 66 + } 67 + 68 + return items; 69 + } 70 + 71 + function processFavs( 72 + ctx: BffContext, 73 + options?: TimelineOptions, 74 + ): TimelineItem[] { 75 + const items: TimelineItem[] = []; 76 + 77 + const whereClause = options?.actorDid 78 + ? [{ field: "did", equals: options.actorDid }] 79 + : undefined; 80 + 81 + const { items: favs } = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 82 + "social.grain.favorite", 83 + { 84 + orderBy: [{ field: "createdAt", direction: "desc" }], 85 + where: whereClause, 86 + }, 87 + ); 88 + 89 + if (favs.length === 0) return items; 90 + 91 + // Collect all gallery references from favorites 92 + const galleryRefs = new Map<string, WithBffMeta<Gallery>>(); 93 + 94 + for (const favorite of favs) { 95 + if (!favorite.subject) continue; 96 + 97 + try { 98 + const atUri = new AtUri(favorite.subject); 99 + const galleryDid = atUri.hostname; 100 + const galleryRkey = atUri.rkey; 101 + const galleryUri = 102 + `at://${galleryDid}/social.grain.gallery/${galleryRkey}`; 103 + 104 + const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 105 + galleryUri, 106 + ); 107 + if (gallery) { 108 + galleryRefs.set(galleryUri, gallery); 109 + } 110 + } catch (e) { 111 + console.error("Error processing favorite:", e); 112 + } 113 + } 114 + 115 + const galleries = Array.from(galleryRefs.values()); 116 + const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 117 + 118 + for (const favorite of favs) { 119 + if (!favorite.subject) continue; 120 + 121 + try { 122 + const atUri = new AtUri(favorite.subject); 123 + const galleryDid = atUri.hostname; 124 + const galleryRkey = atUri.rkey; 125 + const galleryUri = 126 + `at://${galleryDid}/social.grain.gallery/${galleryRkey}`; 127 + 128 + const gallery = galleryRefs.get(galleryUri); 129 + if (!gallery) continue; 130 + 131 + const galleryActor = ctx.indexService.getActor(galleryDid); 132 + if (!galleryActor) continue; 133 + const galleryProfile = getActorProfile(galleryActor.did, ctx); 134 + if (!galleryProfile) continue; 135 + 136 + const favActor = ctx.indexService.getActor(favorite.did); 137 + if (!favActor) continue; 138 + const favProfile = getActorProfile(favActor.did, ctx); 139 + if (!favProfile) continue; 140 + 141 + const galleryPhotos = galleryPhotosMap.get(galleryUri) || []; 142 + const galleryView = galleryToView(gallery, galleryProfile, galleryPhotos); 143 + 144 + items.push({ 145 + itemType: "favorite", 146 + createdAt: favorite.createdAt, 147 + itemUri: favorite.uri, 148 + actor: favProfile, 149 + gallery: galleryView, 150 + }); 151 + } catch (e) { 152 + console.error("Error processing favorite:", e); 153 + continue; 154 + } 155 + } 156 + 157 + return items; 158 + } 159 + 160 + function getTimelineItems( 161 + ctx: BffContext, 162 + options?: TimelineOptions, 163 + ): TimelineItem[] { 164 + const galleryItems = processGalleries(ctx, options); 165 + const favsItems = processFavs(ctx, options); 166 + const timelineItems = [...galleryItems, ...favsItems]; 167 + 168 + return timelineItems.sort( 169 + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 170 + ); 171 + } 172 + 173 + export function getTimeline(ctx: BffContext): TimelineItem[] { 174 + return getTimelineItems(ctx); 175 + } 176 + 177 + export function getActorTimeline(handleOrDid: string, ctx: BffContext) { 178 + let did: string; 179 + if (handleOrDid.includes("did:")) { 180 + did = handleOrDid; 181 + } else { 182 + const actor = ctx.indexService.getActorByHandle(handleOrDid); 183 + if (!actor) return []; 184 + did = actor.did; 185 + } 186 + return getTimelineItems(ctx, { actorDid: did }); 187 + }
+242
src/uploads.tsx
··· 1 + import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 2 + import { BffMiddleware, route, RouteHandler } from "@bigmoves/bff"; 3 + import { BFFPhotoProcessor } from "@bigmoves/bff-photo-processor"; 4 + import { createCanvas, Image } from "@gfx/canvas"; 5 + import { VNode } from "preact"; 6 + import { PhotoPreview } from "./components/PhotoPreview.tsx"; 7 + import { photoThumb } from "./photo.ts"; 8 + 9 + export const photoProcessor = new BFFPhotoProcessor(); 10 + 11 + function uploadStart( 12 + routePrefix: string, 13 + cb: (params: { uploadId: string; src: string; done?: boolean }) => VNode, 14 + ): RouteHandler { 15 + return async (req, _params, ctx) => { 16 + ctx.requireAuth(); 17 + ctx.rateLimit({ 18 + namespace: "upload", 19 + points: 1, 20 + limit: 50, 21 + window: 24 * 60 * 60 * 1000, // 24 hours 22 + }); 23 + const formData = await req.formData(); 24 + const file = formData.get("file") as File; 25 + if (!file) { 26 + return new Response("No file", { status: 400 }); 27 + } 28 + const dataUrl = await compressImageForPreview(file); 29 + if (!ctx.agent) { 30 + return new Response("No agent", { status: 400 }); 31 + } 32 + await photoProcessor.initialize(ctx.agent); 33 + const uploadId = photoProcessor.startUpload(file); 34 + return ctx.html( 35 + <div 36 + id={`upload-id-${uploadId}`} 37 + hx-trigger="done" 38 + hx-get={`/actions/${routePrefix}/upload-done?uploadId=${uploadId}`} 39 + hx-target="this" 40 + hx-swap="outerHTML" 41 + class="h-full w-full" 42 + > 43 + <div 44 + hx-get={`/actions/${routePrefix}/upload-check-status?uploadId=${uploadId}`} 45 + hx-trigger="every 600ms" 46 + hx-target="this" 47 + hx-swap="innerHTML" 48 + class="h-full w-full" 49 + > 50 + {cb({ uploadId, src: dataUrl })} 51 + </div> 52 + </div>, 53 + ); 54 + }; 55 + } 56 + 57 + function uploadCheckStatus(): RouteHandler { 58 + return (req, _params, ctx) => { 59 + ctx.requireAuth(); 60 + const url = new URL(req.url); 61 + const searchParams = new URLSearchParams(url.search); 62 + const uploadId = searchParams.get("uploadId"); 63 + if (!uploadId) return ctx.next(); 64 + const meta = photoProcessor.getUploadStatus(uploadId); 65 + return new Response( 66 + null, 67 + { 68 + status: meta?.blobRef ? 200 : 204, 69 + headers: meta?.blobRef ? { "HX-Trigger": "done" } : {}, 70 + }, 71 + ); 72 + }; 73 + } 74 + 75 + function avatarUploadDone( 76 + cb: (params: { src: string; uploadId: string }) => VNode, 77 + ): RouteHandler { 78 + return (req, _params, ctx) => { 79 + const { did } = ctx.requireAuth(); 80 + const url = new URL(req.url); 81 + const searchParams = new URLSearchParams(url.search); 82 + const uploadId = searchParams.get("uploadId"); 83 + if (!uploadId) return ctx.next(); 84 + const meta = photoProcessor.getUploadStatus(uploadId); 85 + if (!meta?.blobRef) return ctx.next(); 86 + return ctx.html( 87 + cb({ src: photoThumb(did, meta.blobRef.ref.toString()), uploadId }), 88 + ); 89 + }; 90 + } 91 + 92 + function photoUploadDone( 93 + cb: (params: { src: string; uri: string }) => VNode, 94 + ): RouteHandler { 95 + return async (req, _params, ctx) => { 96 + const { did } = ctx.requireAuth(); 97 + const url = new URL(req.url); 98 + const searchParams = new URLSearchParams(url.search); 99 + const uploadId = searchParams.get("uploadId"); 100 + if (!uploadId) return ctx.next(); 101 + const meta = photoProcessor.getUploadStatus(uploadId); 102 + if (!meta?.blobRef) return ctx.next(); 103 + const photoUri = await ctx.createRecord<Photo>("social.grain.photo", { 104 + photo: meta.blobRef, 105 + aspectRatio: meta.dimensions?.width && meta.dimensions?.height 106 + ? { 107 + width: meta.dimensions.width, 108 + height: meta.dimensions.height, 109 + } 110 + : undefined, 111 + alt: "", 112 + createdAt: new Date().toISOString(), 113 + }); 114 + return ctx.html( 115 + cb({ src: photoThumb(did, meta.blobRef.ref.toString()), uri: photoUri }), 116 + ); 117 + }; 118 + } 119 + 120 + export function photoUploadRoutes(): BffMiddleware[] { 121 + return [ 122 + route( 123 + `/actions/photo/upload-start`, 124 + ["POST"], 125 + uploadStart( 126 + "photo", 127 + ({ src }) => <PhotoPreview src={src} />, 128 + ), 129 + ), 130 + route( 131 + `/actions/photo/upload-check-status`, 132 + ["GET"], 133 + uploadCheckStatus(), 134 + ), 135 + route( 136 + `/actions/photo/upload-done`, 137 + ["GET"], 138 + photoUploadDone(({ src, uri }) => ( 139 + <PhotoPreview 140 + src={src} 141 + uri={uri} 142 + /> 143 + )), 144 + ), 145 + ]; 146 + } 147 + 148 + export function avatarUploadRoutes(): BffMiddleware[] { 149 + return [ 150 + route( 151 + `/actions/avatar/upload-start`, 152 + ["POST"], 153 + uploadStart("avatar", ({ src }) => ( 154 + <img 155 + src={src} 156 + alt="" 157 + data-state="pending" 158 + class="rounded-full w-full h-full object-cover data-[state=pending]:opacity-50" 159 + /> 160 + )), 161 + ), 162 + route( 163 + `/actions/avatar/upload-check-status`, 164 + ["GET"], 165 + uploadCheckStatus(), 166 + ), 167 + route( 168 + `/actions/avatar/upload-done`, 169 + ["GET"], 170 + avatarUploadDone(({ src, uploadId }) => ( 171 + <> 172 + <div hx-swap-oob="innerHTML:#image-input"> 173 + <input type="hidden" name="uploadId" value={uploadId} /> 174 + </div> 175 + <img 176 + src={src} 177 + alt="" 178 + class="rounded-full w-full h-full object-cover" 179 + /> 180 + </> 181 + )), 182 + ), 183 + ]; 184 + } 185 + 186 + function readFileAsDataURL(file: File): Promise<string> { 187 + return new Promise((resolve, reject) => { 188 + const reader = new FileReader(); 189 + reader.onload = (e) => resolve(e.target?.result as string); 190 + reader.onerror = (e) => reject(e); 191 + reader.readAsDataURL(file); 192 + }); 193 + } 194 + 195 + function createImageFromDataURL(dataURL: string): Promise<Image> { 196 + return new Promise((resolve) => { 197 + const img = new Image(); 198 + img.onload = () => resolve(img); 199 + img.src = dataURL; 200 + }); 201 + } 202 + 203 + async function compressImageForPreview(file: File): Promise<string> { 204 + const maxWidth = 500, 205 + maxHeight = 500, 206 + format = "jpeg"; 207 + 208 + // Create an image from the file 209 + const dataUrl = await readFileAsDataURL(file); 210 + const img = await createImageFromDataURL(dataUrl); 211 + 212 + // Create a canvas with reduced dimensions 213 + const canvas = createCanvas(img.width, img.height); 214 + let width = img.width; 215 + let height = img.height; 216 + 217 + // Calculate new dimensions while maintaining aspect ratio 218 + if (width > height) { 219 + if (width > maxWidth) { 220 + height = Math.round((height * maxWidth) / width); 221 + width = maxWidth; 222 + } 223 + } else { 224 + if (height > maxHeight) { 225 + width = Math.round((width * maxHeight) / height); 226 + height = maxHeight; 227 + } 228 + } 229 + 230 + canvas.width = width; 231 + canvas.height = height; 232 + 233 + // Draw and compress the image 234 + const ctx = canvas.getContext("2d"); 235 + if (!ctx) { 236 + throw new Error("Failed to get canvas context"); 237 + } 238 + ctx.drawImage(img, 0, 0, width, height); 239 + 240 + // Convert to compressed image data URL 241 + return canvas.toDataURL(format); 242 + }
+119
src/utils.ts
··· 1 + import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts"; 2 + import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 3 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 4 + import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { onSignedInArgs } from "@bigmoves/bff"; 7 + import { join } from "@std/path/join"; 8 + import { 9 + differenceInDays, 10 + differenceInHours, 11 + differenceInMinutes, 12 + differenceInWeeks, 13 + } from "date-fns"; 14 + import { PUBLIC_URL } from "./env.ts"; 15 + 16 + export function formatRelativeTime(date: Date) { 17 + const now = new Date(); 18 + const weeks = differenceInWeeks(now, date); 19 + if (weeks > 0) return `${weeks}w`; 20 + 21 + const days = differenceInDays(now, date); 22 + if (days > 0) return `${days}d`; 23 + 24 + const hours = differenceInHours(now, date); 25 + if (hours > 0) return `${hours}h`; 26 + 27 + const minutes = differenceInMinutes(now, date); 28 + return `${Math.max(1, minutes)}m`; 29 + } 30 + 31 + export function profileLink(handle: string) { 32 + return `/profile/${handle}`; 33 + } 34 + 35 + export function galleryLink(handle: string, galleryRkey: string) { 36 + return `/profile/${handle}/gallery/${galleryRkey}`; 37 + } 38 + 39 + export function photoDialogLink(gallery: GalleryView, image: PhotoView) { 40 + return `/dialogs/image?galleryUri=${gallery.uri}&imageCid=${image.cid}`; 41 + } 42 + 43 + export function publicGalleryLink(handle: string, galleryUri: string): string { 44 + return `${PUBLIC_URL}${galleryLink(handle, new AtUri(galleryUri).rkey)}`; 45 + } 46 + 47 + export async function onSignedIn({ actor, ctx }: onSignedInArgs) { 48 + await ctx.backfillCollections( 49 + [actor.did], 50 + [ 51 + ...ctx.cfg.collections!, 52 + "app.bsky.actor.profile", 53 + "app.bsky.graph.follow", 54 + ], 55 + ); 56 + 57 + const profileResults = ctx.indexService.getRecords<Profile>( 58 + "social.grain.actor.profile", 59 + { 60 + where: [{ field: "did", equals: actor.did }], 61 + }, 62 + ); 63 + 64 + const profile = profileResults.items[0]; 65 + 66 + if (profile) { 67 + console.log("Profile already exists"); 68 + return `/profile/${actor.handle}`; 69 + } 70 + 71 + const bskyProfileResults = ctx.indexService.getRecords<BskyProfile>( 72 + "app.bsky.actor.profile", 73 + { 74 + where: [{ field: "did", equals: actor.did }], 75 + }, 76 + ); 77 + 78 + const bskyProfile = bskyProfileResults.items[0]; 79 + 80 + if (!bskyProfile) { 81 + console.error("Failed to get profile"); 82 + return; 83 + } 84 + 85 + await ctx.createRecord<Profile>( 86 + "social.grain.actor.profile", 87 + { 88 + displayName: bskyProfile.displayName ?? undefined, 89 + description: bskyProfile.description ?? undefined, 90 + avatar: bskyProfile.avatar ?? undefined, 91 + createdAt: new Date().toISOString(), 92 + }, 93 + true, 94 + ); 95 + 96 + return "/onboard"; 97 + } 98 + 99 + export async function generateStaticFilesHash(): Promise<Map<string, string>> { 100 + const staticFilesHash = new Map<string, string>(); 101 + 102 + for (const entry of Deno.readDirSync(join(Deno.cwd(), "static"))) { 103 + if ( 104 + entry.isFile && 105 + (entry.name.endsWith(".js") || entry.name.endsWith(".css")) 106 + ) { 107 + const fileContent = await Deno.readFile( 108 + join(Deno.cwd(), "static", entry.name), 109 + ); 110 + const hashBuffer = await crypto.subtle.digest("SHA-256", fileContent); 111 + const hash = Array.from(new Uint8Array(hashBuffer)) 112 + .map((b) => b.toString(16).padStart(2, "0")) 113 + .join(""); 114 + staticFilesHash.set(entry.name, hash); 115 + } 116 + } 117 + 118 + return staticFilesHash; 119 + }