grain.social is a photo sharing platform built on atproto.
1import { lexicons } from "$lexicon/lexicons.ts"; 2import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts"; 3import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 4import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 5import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 6import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 7import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 8import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 9import { 10 isRecord as isPhoto, 11 Record as Photo, 12} from "$lexicon/types/social/grain/photo.ts"; 13import { 14 isPhotoView, 15 PhotoView, 16} from "$lexicon/types/social/grain/photo/defs.ts"; 17import { $Typed, Un$Typed } from "$lexicon/util.ts"; 18import { AtUri } from "@atproto/syntax"; 19import { 20 bff, 21 BffContext, 22 BffMiddleware, 23 CSS, 24 JETSTREAM, 25 oauth, 26 OAUTH_ROUTES, 27 onSignedInArgs, 28 requireAuth, 29 RootProps, 30 route, 31 RouteHandler, 32 UnauthorizedError, 33 WithBffMeta, 34} from "@bigmoves/bff"; 35import { 36 Button, 37 cn, 38 Dialog, 39 Input, 40 Layout, 41 Login, 42 Meta, 43 type MetaDescriptor, 44 Textarea, 45} from "@bigmoves/bff/components"; 46import { createCanvas, Image } from "@gfx/canvas"; 47import { join } from "@std/path"; 48import { formatDistanceStrict } from "date-fns"; 49import { wrap } from "popmotion"; 50import { ComponentChildren, JSX, VNode } from "preact"; 51 52const PUBLIC_URL = Deno.env.get("BFF_PUBLIC_URL") ?? "http://localhost:8080"; 53const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 54 55let cssContentHash: string = ""; 56 57bff({ 58 appName: "Grain Social", 59 collections: [ 60 "social.grain.gallery", 61 "social.grain.actor.profile", 62 "social.grain.photo", 63 "social.grain.favorite", 64 "social.grain.gallery.item", 65 ], 66 jetstreamUrl: JETSTREAM.WEST_1, 67 lexicons, 68 rootElement: Root, 69 onListen: async () => { 70 const cssFileContent = await Deno.readFile( 71 join(Deno.cwd(), "static", "styles.css"), 72 ); 73 const hashBuffer = await crypto.subtle.digest("SHA-256", cssFileContent); 74 cssContentHash = Array.from(new Uint8Array(hashBuffer)) 75 .map((b) => b.toString(16).padStart(2, "0")) 76 .join(""); 77 }, 78 onError: (err) => { 79 if (err instanceof UnauthorizedError) { 80 const ctx = err.ctx; 81 return ctx.redirect(OAUTH_ROUTES.loginPage); 82 } 83 return new Response("Internal Server Error", { 84 status: 500, 85 }); 86 }, 87 middlewares: [ 88 (_req, ctx) => { 89 if (ctx.currentUser) { 90 const profile = getActorProfile(ctx.currentUser.did, ctx); 91 if (profile) { 92 ctx.state.profile = profile; 93 return ctx.next(); 94 } 95 } 96 return ctx.next(); 97 }, 98 oauth({ 99 onSignedIn, 100 LoginComponent: ({ error }) => ( 101 <div 102 id="login" 103 class="flex justify-center items-center w-full h-full relative" 104 style="background-image: url('https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreiewhwu3ro5dv7omedphb62db4koa7qtvyzfhiiypg3ru4tvuxkrjy@webp'); background-size: cover; background-position: center;" 105 > 106 <Login hx-target="#login" error={error} errorClass="text-white" /> 107 <div class="absolute bottom-2 right-2 text-white text-sm"> 108 Photo by{" "} 109 <a 110 href={profileLink("chadtmiller.com")} 111 class="hover:underline font-semibold" 112 > 113 @chadtmiller.com 114 </a> 115 </div> 116 </div> 117 ), 118 }), 119 route("/", (_req, _params, ctx) => { 120 const items = getTimeline(ctx); 121 ctx.state.meta = [{ title: "Timeline — Grain" }, getPageMeta("")]; 122 return ctx.render(<Timeline items={items} />); 123 }), 124 route("/profile/:handle", (req, params, ctx) => { 125 const url = new URL(req.url); 126 const tab = url.searchParams.get("tab"); 127 const handle = params.handle; 128 const timelineItems = getActorTimeline(handle, ctx); 129 const galleries = getActorGalleries(handle, ctx); 130 const actor = ctx.indexService.getActorByHandle(handle); 131 if (!actor) return ctx.next(); 132 const profile = getActorProfile(actor.did, ctx); 133 if (!profile) return ctx.next(); 134 ctx.state.meta = [ 135 { 136 title: profile.displayName 137 ? `${profile.displayName} (${profile.handle}) — Grain` 138 : `${profile.handle} — Grain`, 139 }, 140 getPageMeta(profileLink(handle)), 141 ]; 142 if (tab) { 143 return ctx.html( 144 <ProfilePage 145 loggedInUserDid={ctx.currentUser?.did} 146 timelineItems={timelineItems} 147 profile={profile} 148 selectedTab={tab} 149 galleries={galleries} 150 />, 151 ); 152 } 153 return ctx.render( 154 <ProfilePage 155 loggedInUserDid={ctx.currentUser?.did} 156 timelineItems={timelineItems} 157 profile={profile} 158 />, 159 ); 160 }), 161 route("/profile/:handle/:rkey", (_req, params, ctx: BffContext<State>) => { 162 const did = ctx.currentUser?.did; 163 let favs: WithBffMeta<Favorite>[] = []; 164 const handle = params.handle; 165 const rkey = params.rkey; 166 const gallery = getGallery(handle, rkey, ctx); 167 if (!gallery) return ctx.next(); 168 favs = getGalleryFavs(gallery.uri, ctx); 169 ctx.state.meta = [ 170 { title: `${(gallery.record as Gallery).title} — Grain` }, 171 ...getPageMeta(galleryLink(handle, rkey)), 172 ...getGalleryMeta(gallery), 173 ]; 174 ctx.state.scripts = ["photo_dialog.js", "masonry.js"]; 175 return ctx.render( 176 <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, 177 ); 178 }), 179 route("/upload", (req, _params, ctx) => { 180 requireAuth(ctx); 181 const url = new URL(req.url); 182 const galleryRkey = url.searchParams.get("returnTo"); 183 const photos = getActorPhotos(ctx.currentUser.did, ctx); 184 ctx.state.meta = [{ title: "Upload — Grain" }, getPageMeta("/upload")]; 185 return ctx.render( 186 <UploadPage 187 handle={ctx.currentUser.handle} 188 photos={photos} 189 returnTo={galleryRkey 190 ? galleryLink(ctx.currentUser.handle, galleryRkey) 191 : undefined} 192 />, 193 ); 194 }), 195 route("/dialogs/gallery/new", (_req, _params, ctx) => { 196 requireAuth(ctx); 197 return ctx.html(<GalleryCreateEditDialog />); 198 }), 199 route("/dialogs/gallery/:rkey", (_req, params, ctx) => { 200 requireAuth(ctx); 201 const handle = ctx.currentUser.handle; 202 const rkey = params.rkey; 203 const gallery = getGallery(handle, rkey, ctx); 204 return ctx.html(<GalleryCreateEditDialog gallery={gallery} />); 205 }), 206 route("/onboard", (_req, _params, ctx) => { 207 requireAuth(ctx); 208 return ctx.render( 209 <div 210 hx-get="/dialogs/profile" 211 hx-trigger="load" 212 hx-target="body" 213 hx-swap="afterbegin" 214 />, 215 ); 216 }), 217 route("/dialogs/profile", (_req, _params, ctx: BffContext<State>) => { 218 requireAuth(ctx); 219 220 if (!ctx.state.profile) return ctx.next(); 221 222 const profileRecord = ctx.indexService.getRecord<Profile>( 223 `at://${ctx.currentUser.did}/social.grain.actor.profile/self`, 224 ); 225 226 if (!profileRecord) return ctx.next(); 227 228 return ctx.html( 229 <ProfileDialog 230 profile={ctx.state.profile} 231 avatarCid={profileRecord.avatar?.ref.toString()} 232 />, 233 ); 234 }), 235 route("/dialogs/avatar/:handle", (_req, params, ctx) => { 236 const handle = params.handle; 237 const actor = ctx.indexService.getActorByHandle(handle); 238 if (!actor) return ctx.next(); 239 const profile = getActorProfile(actor.did, ctx); 240 if (!profile) return ctx.next(); 241 return ctx.html(<AvatarDialog profile={profile} />); 242 }), 243 route("/dialogs/image", (req, _params, ctx) => { 244 const url = new URL(req.url); 245 const galleryUri = url.searchParams.get("galleryUri"); 246 const imageCid = url.searchParams.get("imageCid"); 247 if (!galleryUri || !imageCid) return ctx.next(); 248 const atUri = new AtUri(galleryUri); 249 const galleryDid = atUri.hostname; 250 const galleryRkey = atUri.rkey; 251 const gallery = getGallery(galleryDid, galleryRkey, ctx); 252 if (!gallery?.items) return ctx.next(); 253 const image = gallery.items.filter(isPhotoView).find((item) => { 254 return item.cid === imageCid; 255 }); 256 const imageAtIndex = gallery.items 257 .filter(isPhotoView) 258 .findIndex((image) => { 259 return image.cid === imageCid; 260 }); 261 const next = wrap(0, gallery.items.length, imageAtIndex + 1); 262 const prev = wrap(0, gallery.items.length, imageAtIndex - 1); 263 if (!image) return ctx.next(); 264 return ctx.html( 265 <PhotoDialog 266 gallery={gallery} 267 image={image} 268 nextImage={gallery.items.filter(isPhotoView).at(next)} 269 prevImage={gallery.items.filter(isPhotoView).at(prev)} 270 />, 271 ); 272 }), 273 route("/dialogs/image-alt", (req, _params, ctx) => { 274 const url = new URL(req.url); 275 const galleryUri = url.searchParams.get("galleryUri"); 276 const imageCid = url.searchParams.get("imageCid"); 277 if (!galleryUri || !imageCid) return ctx.next(); 278 const atUri = new AtUri(galleryUri); 279 const galleryDid = atUri.hostname; 280 const galleryRkey = atUri.rkey; 281 const gallery = getGallery(galleryDid, galleryRkey, ctx); 282 const photo = gallery?.items?.filter(isPhotoView).find((photo) => { 283 return photo.cid === imageCid; 284 }); 285 if (!photo || !gallery) return ctx.next(); 286 return ctx.html( 287 <PhotoAltDialog galleryUri={gallery.uri} photo={photo} />, 288 ); 289 }), 290 route("/dialogs/photo-select/:galleryRkey", (_req, params, ctx) => { 291 requireAuth(ctx); 292 const photos = getActorPhotos(ctx.currentUser.did, ctx); 293 const galleryUri = 294 `at://${ctx.currentUser.did}/social.grain.gallery/${params.galleryRkey}`; 295 const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 296 galleryUri, 297 ); 298 if (!gallery) return ctx.next(); 299 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]); 300 const itemUris = galleryPhotosMap.get(galleryUri)?.map((photo) => 301 photo.uri 302 ) ?? []; 303 return ctx.html( 304 <PhotoSelectDialog 305 galleryUri={galleryUri} 306 itemUris={itemUris} 307 photos={photos} 308 />, 309 ); 310 }), 311 route("/actions/create-edit", ["POST"], async (req, _params, ctx) => { 312 requireAuth(ctx); 313 const formData = await req.formData(); 314 const title = formData.get("title") as string; 315 const description = formData.get("description") as string; 316 const url = new URL(req.url); 317 const searchParams = new URLSearchParams(url.search); 318 const uri = searchParams.get("uri"); 319 const handle = ctx.currentUser?.handle; 320 321 if (uri) { 322 const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(uri); 323 if (!gallery) return ctx.next(); 324 const rkey = new AtUri(uri).rkey; 325 try { 326 await ctx.updateRecord<Gallery>("social.grain.gallery", rkey, { 327 title, 328 description, 329 createdAt: gallery.createdAt, 330 }); 331 } catch (e) { 332 console.error("Error updating record:", e); 333 const errorMessage = e instanceof Error 334 ? e.message 335 : "Unknown error occurred"; 336 return new Response(errorMessage, { status: 400 }); 337 } 338 return ctx.redirect(galleryLink(handle, rkey)); 339 } 340 341 const createdUri = await ctx.createRecord<Gallery>( 342 "social.grain.gallery", 343 { 344 title, 345 description, 346 createdAt: new Date().toISOString(), 347 }, 348 ); 349 return ctx.redirect(galleryLink(handle, new AtUri(createdUri).rkey)); 350 }), 351 route("/actions/gallery/delete", ["POST"], async (req, _params, ctx) => { 352 requireAuth(ctx); 353 const formData = await req.formData(); 354 const uri = formData.get("uri") as string; 355 await ctx.deleteRecord(uri); 356 return ctx.redirect("/"); 357 }), 358 route( 359 "/actions/gallery/:galleryRkey/add-photo/:photoRkey", 360 ["PUT"], 361 async (_req, params, ctx) => { 362 requireAuth(ctx); 363 const galleryRkey = params.galleryRkey; 364 const photoRkey = params.photoRkey; 365 const galleryUri = 366 `at://${ctx.currentUser.did}/social.grain.gallery/${galleryRkey}`; 367 const photoUri = 368 `at://${ctx.currentUser.did}/social.grain.photo/${photoRkey}`; 369 const gallery = getGallery(ctx.currentUser.did, galleryRkey, ctx); 370 const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 371 if (!gallery || !photo) return ctx.next(); 372 if ( 373 gallery.items 374 ?.filter(isPhotoView) 375 .some((item) => item.uri === photoUri) 376 ) { 377 return new Response(null, { status: 500 }); 378 } 379 await ctx.createRecord<Gallery>("social.grain.gallery.item", { 380 gallery: galleryUri, 381 item: photoUri, 382 createdAt: new Date().toISOString(), 383 }); 384 gallery.items = [ 385 ...(gallery.items ?? []), 386 photoToView(photo.did, photo), 387 ]; 388 return ctx.html( 389 <> 390 <div hx-swap-oob="beforeend:#masonry-container"> 391 <PhotoButton 392 key={photo.cid} 393 photo={photoToView(photo.did, photo)} 394 gallery={gallery} 395 isCreator={ctx.currentUser.did === gallery.creator.did} 396 isLoggedIn={!!ctx.currentUser.did} 397 /> 398 </div> 399 <PhotoSelectButton 400 galleryUri={galleryUri} 401 itemUris={gallery.items?.filter(isPhotoView).map((item) => 402 item.uri 403 ) ?? []} 404 photo={photoToView(photo.did, photo)} 405 /> 406 </>, 407 ); 408 }, 409 ), 410 route( 411 "/actions/gallery/:galleryRkey/remove-photo/:photoRkey", 412 ["PUT"], 413 async (_req, params, ctx) => { 414 requireAuth(ctx); 415 const galleryRkey = params.galleryRkey; 416 const photoRkey = params.photoRkey; 417 const galleryUri = 418 `at://${ctx.currentUser.did}/social.grain.gallery/${galleryRkey}`; 419 const photoUri = 420 `at://${ctx.currentUser.did}/social.grain.photo/${photoRkey}`; 421 if (!galleryRkey || !photoRkey) return ctx.next(); 422 const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 423 if (!photo) return ctx.next(); 424 const { 425 items: [item], 426 } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 427 "social.grain.gallery.item", 428 { 429 where: [ 430 { 431 field: "gallery", 432 equals: galleryUri, 433 }, 434 { 435 field: "item", 436 equals: photoUri, 437 }, 438 ], 439 }, 440 ); 441 if (!item) return ctx.next(); 442 await ctx.deleteRecord(item.uri); 443 const gallery = getGallery(ctx.currentUser.did, galleryRkey, ctx); 444 if (!gallery) return ctx.next(); 445 return ctx.html( 446 <PhotoSelectButton 447 galleryUri={galleryUri} 448 itemUris={gallery.items?.filter(isPhotoView).map((item) => 449 item.uri 450 ) ?? []} 451 photo={photoToView(photo.did, photo)} 452 />, 453 ); 454 }, 455 ), 456 route("/actions/photo/:rkey", ["PUT"], async (req, params, ctx) => { 457 requireAuth(ctx); 458 const photoRkey = params.rkey; 459 const formData = await req.formData(); 460 const alt = formData.get("alt") as string; 461 const photoUri = 462 `at://${ctx.currentUser.did}/social.grain.photo/${photoRkey}`; 463 const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 464 if (!photo) return ctx.next(); 465 await ctx.updateRecord<Photo>("social.grain.photo", photoRkey, { 466 photo: photo.photo, 467 aspectRatio: photo.aspectRatio, 468 alt, 469 createdAt: photo.createdAt, 470 }); 471 return new Response(null, { status: 200 }); 472 }), 473 route("/actions/favorite", ["POST"], async (req, _params, ctx) => { 474 requireAuth(ctx); 475 const url = new URL(req.url); 476 const searchParams = new URLSearchParams(url.search); 477 const galleryUri = searchParams.get("galleryUri"); 478 const favUri = searchParams.get("favUri") ?? undefined; 479 if (!galleryUri) return ctx.next(); 480 481 if (favUri) { 482 await ctx.deleteRecord(favUri); 483 const favs = getGalleryFavs(galleryUri, ctx); 484 return ctx.html( 485 <FavoriteButton 486 currentUserDid={ctx.currentUser.did} 487 favs={favs} 488 galleryUri={galleryUri} 489 />, 490 ); 491 } 492 493 await ctx.createRecord<WithBffMeta<Favorite>>("social.grain.favorite", { 494 subject: galleryUri, 495 createdAt: new Date().toISOString(), 496 }); 497 498 const favs = getGalleryFavs(galleryUri, ctx); 499 500 return ctx.html( 501 <FavoriteButton 502 currentUserDid={ctx.currentUser.did} 503 galleryUri={galleryUri} 504 favs={favs} 505 />, 506 ); 507 }), 508 route("/actions/profile/update", ["POST"], async (req, _params, ctx) => { 509 requireAuth(ctx); 510 const formData = await req.formData(); 511 const displayName = formData.get("displayName") as string; 512 const description = formData.get("description") as string; 513 const avatarCid = formData.get("avatarCid") as string; 514 515 const record = ctx.indexService.getRecord<Profile>( 516 `at://${ctx.currentUser.did}/social.grain.actor.profile/self`, 517 ); 518 519 if (!record) { 520 return new Response("Profile record not found", { status: 404 }); 521 } 522 523 await ctx.updateRecord<Profile>("social.grain.actor.profile", "self", { 524 displayName, 525 description, 526 avatar: ctx.blobMetaCache.get(avatarCid)?.blobRef ?? record.avatar, 527 }); 528 529 return ctx.redirect(`/profile/${ctx.currentUser.handle}`); 530 }), 531 route("/actions/photo/:rkey", ["DELETE"], (_req, params, ctx) => { 532 requireAuth(ctx); 533 ctx.deleteRecord( 534 `at://${ctx.currentUser.did}/social.grain.photo/${params.rkey}`, 535 ); 536 return new Response(null, { status: 200 }); 537 }), 538 ...photoUploadRoutes(), 539 ...avatarUploadRoutes(), 540 ], 541}); 542 543type State = { 544 profile?: ProfileView; 545 scripts?: string[]; 546 meta?: MetaDescriptor[]; 547}; 548 549function readFileAsDataURL(file: File): Promise<string> { 550 return new Promise((resolve, reject) => { 551 const reader = new FileReader(); 552 reader.onload = (e) => resolve(e.target?.result as string); 553 reader.onerror = (e) => reject(e); 554 reader.readAsDataURL(file); 555 }); 556} 557 558function createImageFromDataURL(dataURL: string): Promise<Image> { 559 return new Promise((resolve) => { 560 const img = new Image(); 561 img.onload = () => resolve(img); 562 img.src = dataURL; 563 }); 564} 565 566async function compressImageForPreview(file: File): Promise<string> { 567 const maxWidth = 500, 568 maxHeight = 500, 569 format = "jpeg"; 570 571 // Create an image from the file 572 const dataUrl = await readFileAsDataURL(file); 573 const img = await createImageFromDataURL(dataUrl); 574 575 // Create a canvas with reduced dimensions 576 const canvas = createCanvas(img.width, img.height); 577 let width = img.width; 578 let height = img.height; 579 580 // Calculate new dimensions while maintaining aspect ratio 581 if (width > height) { 582 if (width > maxWidth) { 583 height = Math.round((height * maxWidth) / width); 584 width = maxWidth; 585 } 586 } else { 587 if (height > maxHeight) { 588 width = Math.round((width * maxHeight) / height); 589 height = maxHeight; 590 } 591 } 592 593 canvas.width = width; 594 canvas.height = height; 595 596 // Draw and compress the image 597 const ctx = canvas.getContext("2d"); 598 if (!ctx) { 599 throw new Error("Failed to get canvas context"); 600 } 601 ctx.drawImage(img, 0, 0, width, height); 602 603 // Convert to compressed image data URL 604 return canvas.toDataURL(format); 605} 606 607type TimelineItemType = "gallery" | "favorite"; 608 609type TimelineItem = { 610 createdAt: string; 611 itemType: TimelineItemType; 612 itemUri: string; 613 actor: Un$Typed<ProfileView>; 614 gallery: GalleryView; 615}; 616 617type TimelineOptions = { 618 actorDid?: string; 619}; 620 621function getGalleryItemsAndPhotos( 622 ctx: BffContext, 623 galleries: WithBffMeta<Gallery>[], 624): Map<string, WithBffMeta<Photo>[]> { 625 const galleryUris = galleries.map( 626 (gallery) => 627 `at://${gallery.did}/social.grain.gallery/${new AtUri(gallery.uri).rkey}`, 628 ); 629 630 if (galleryUris.length === 0) return new Map(); 631 632 const { items: galleryItems } = ctx.indexService.getRecords< 633 WithBffMeta<GalleryItem> 634 >("social.grain.gallery.item", { 635 where: [{ field: "gallery", in: galleryUris }], 636 }); 637 638 const photoUris = galleryItems.map((item) => item.item).filter(Boolean); 639 if (photoUris.length === 0) return new Map(); 640 641 const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>( 642 "social.grain.photo", 643 { 644 where: [{ field: "uri", in: photoUris }], 645 }, 646 ); 647 648 const photosMap = new Map<string, WithBffMeta<Photo>>(); 649 for (const photo of photos) { 650 photosMap.set(photo.uri, photo); 651 } 652 653 const galleryPhotosMap = new Map<string, WithBffMeta<Photo>[]>(); 654 for (const item of galleryItems) { 655 const galleryUri = item.gallery; 656 const photo = photosMap.get(item.item); 657 658 if (!galleryPhotosMap.has(galleryUri)) { 659 galleryPhotosMap.set(galleryUri, []); 660 } 661 662 if (photo) { 663 galleryPhotosMap.get(galleryUri)?.push(photo); 664 } 665 } 666 667 return galleryPhotosMap; 668} 669 670function processGalleries( 671 ctx: BffContext, 672 options?: TimelineOptions, 673): TimelineItem[] { 674 const items: TimelineItem[] = []; 675 676 const whereClause = options?.actorDid 677 ? [{ field: "did", equals: options.actorDid }] 678 : undefined; 679 680 const { items: galleries } = ctx.indexService.getRecords< 681 WithBffMeta<Gallery> 682 >("social.grain.gallery", { 683 orderBy: { field: "createdAt", direction: "desc" }, 684 where: whereClause, 685 }); 686 687 if (galleries.length === 0) return items; 688 689 // Get photos for all galleries 690 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 691 692 for (const gallery of galleries) { 693 const actor = ctx.indexService.getActor(gallery.did); 694 if (!actor) continue; 695 const profile = getActorProfile(actor.did, ctx); 696 if (!profile) continue; 697 698 const galleryUri = `at://${gallery.did}/social.grain.gallery/${ 699 new AtUri(gallery.uri).rkey 700 }`; 701 const galleryPhotos = galleryPhotosMap.get(galleryUri) || []; 702 703 const galleryView = galleryToView(gallery, profile, galleryPhotos); 704 items.push({ 705 itemType: "gallery", 706 createdAt: gallery.createdAt, 707 itemUri: galleryView.uri, 708 actor: galleryView.creator, 709 gallery: galleryView, 710 }); 711 } 712 713 return items; 714} 715 716function processStars( 717 ctx: BffContext, 718 options?: TimelineOptions, 719): TimelineItem[] { 720 const items: TimelineItem[] = []; 721 722 const whereClause = options?.actorDid 723 ? [{ field: "did", equals: options.actorDid }] 724 : undefined; 725 726 const { items: favs } = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 727 "social.grain.favorite", 728 { 729 orderBy: { field: "createdAt", direction: "desc" }, 730 where: whereClause, 731 }, 732 ); 733 734 if (favs.length === 0) return items; 735 736 // Collect all gallery references from favorites 737 const galleryRefs = new Map<string, WithBffMeta<Gallery>>(); 738 739 for (const favorite of favs) { 740 if (!favorite.subject) continue; 741 742 try { 743 const atUri = new AtUri(favorite.subject); 744 const galleryDid = atUri.hostname; 745 const galleryRkey = atUri.rkey; 746 const galleryUri = 747 `at://${galleryDid}/social.grain.gallery/${galleryRkey}`; 748 749 const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 750 galleryUri, 751 ); 752 if (gallery) { 753 galleryRefs.set(galleryUri, gallery); 754 } 755 } catch (e) { 756 console.error("Error processing favorite:", e); 757 } 758 } 759 760 const galleries = Array.from(galleryRefs.values()); 761 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 762 763 for (const favorite of favs) { 764 if (!favorite.subject) continue; 765 766 try { 767 const atUri = new AtUri(favorite.subject); 768 const galleryDid = atUri.hostname; 769 const galleryRkey = atUri.rkey; 770 const galleryUri = 771 `at://${galleryDid}/social.grain.gallery/${galleryRkey}`; 772 773 const gallery = galleryRefs.get(galleryUri); 774 if (!gallery) continue; 775 776 const galleryActor = ctx.indexService.getActor(galleryDid); 777 if (!galleryActor) continue; 778 const galleryProfile = getActorProfile(galleryActor.did, ctx); 779 if (!galleryProfile) continue; 780 781 const favActor = ctx.indexService.getActor(favorite.did); 782 if (!favActor) continue; 783 const favProfile = getActorProfile(favActor.did, ctx); 784 if (!favProfile) continue; 785 786 const galleryPhotos = galleryPhotosMap.get(galleryUri) || []; 787 const galleryView = galleryToView(gallery, galleryProfile, galleryPhotos); 788 789 items.push({ 790 itemType: "favorite", 791 createdAt: favorite.createdAt, 792 itemUri: favorite.uri, 793 actor: favProfile, 794 gallery: galleryView, 795 }); 796 } catch (e) { 797 console.error("Error processing favorite:", e); 798 continue; 799 } 800 } 801 802 return items; 803} 804 805function getTimelineItems( 806 ctx: BffContext, 807 options?: TimelineOptions, 808): TimelineItem[] { 809 const galleryItems = processGalleries(ctx, options); 810 const favsItems = processStars(ctx, options); 811 const timelineItems = [...galleryItems, ...favsItems]; 812 813 return timelineItems.sort( 814 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 815 ); 816} 817 818function getTimeline(ctx: BffContext): TimelineItem[] { 819 return getTimelineItems(ctx); 820} 821 822function getActorTimeline(handleOrDid: string, ctx: BffContext) { 823 let did: string; 824 if (handleOrDid.includes("did:")) { 825 did = handleOrDid; 826 } else { 827 const actor = ctx.indexService.getActorByHandle(handleOrDid); 828 if (!actor) return []; 829 did = actor.did; 830 } 831 return getTimelineItems(ctx, { actorDid: did }); 832} 833 834function getActorPhotos(handleOrDid: string, ctx: BffContext) { 835 let did: string; 836 if (handleOrDid.includes("did:")) { 837 did = handleOrDid; 838 } else { 839 const actor = ctx.indexService.getActorByHandle(handleOrDid); 840 if (!actor) return []; 841 did = actor.did; 842 } 843 const photos = ctx.indexService.getRecords<WithBffMeta<Photo>>( 844 "social.grain.photo", 845 { 846 where: [{ field: "did", equals: did }], 847 orderBy: { field: "createdAt", direction: "desc" }, 848 }, 849 ); 850 return photos.items.map((photo) => photoToView(photo.did, photo)); 851} 852 853function getActorGalleries(handleOrDid: string, ctx: BffContext) { 854 let did: string; 855 if (handleOrDid.includes("did:")) { 856 did = handleOrDid; 857 } else { 858 const actor = ctx.indexService.getActorByHandle(handleOrDid); 859 if (!actor) return []; 860 did = actor.did; 861 } 862 const { items: galleries } = ctx.indexService.getRecords< 863 WithBffMeta<Gallery> 864 >("social.grain.gallery", { 865 where: [{ field: "did", equals: did }], 866 orderBy: { field: "createdAt", direction: "desc" }, 867 }); 868 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries); 869 const creator = getActorProfile(did, ctx); 870 if (!creator) return []; 871 return galleries.map((gallery) => 872 galleryToView(gallery, creator, galleryPhotosMap.get(gallery.uri) ?? []) 873 ); 874} 875 876function getGallery(handleOrDid: string, rkey: string, ctx: BffContext) { 877 let did: string; 878 if (handleOrDid.includes("did:")) { 879 did = handleOrDid; 880 } else { 881 const actor = ctx.indexService.getActorByHandle(handleOrDid); 882 if (!actor) return null; 883 did = actor.did; 884 } 885 const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 886 `at://${did}/social.grain.gallery/${rkey}`, 887 ); 888 if (!gallery) return null; 889 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]); 890 const profile = getActorProfile(did, ctx); 891 if (!profile) return null; 892 return galleryToView( 893 gallery, 894 profile, 895 galleryPhotosMap.get(gallery.uri) ?? [], 896 ); 897} 898 899function getGalleryFavs(galleryUri: string, ctx: BffContext) { 900 const atUri = new AtUri(galleryUri); 901 const results = ctx.indexService.getRecords<WithBffMeta<Favorite>>( 902 "social.grain.favorite", 903 { 904 where: [ 905 { 906 field: "subject", 907 equals: `at://${atUri.hostname}/social.grain.gallery/${atUri.rkey}`, 908 }, 909 ], 910 }, 911 ); 912 return results.items; 913} 914 915function getPageMeta(pageUrl: string): MetaDescriptor[] { 916 return [ 917 { 918 tagName: "link", 919 property: "canonical", 920 href: `${PUBLIC_URL}${pageUrl}`, 921 }, 922 { property: "og:site_name", content: "Grain Social" }, 923 ]; 924} 925 926function getGalleryMeta(gallery: GalleryView): MetaDescriptor[] { 927 return [ 928 // { property: "og:type", content: "website" }, 929 { 930 property: "og:url", 931 content: `${PUBLIC_URL}/profile/${gallery.creator.handle}/${ 932 new AtUri(gallery.uri).rkey 933 }`, 934 }, 935 { property: "og:title", content: (gallery.record as Gallery).title }, 936 { 937 property: "og:description", 938 content: (gallery.record as Gallery).description, 939 }, 940 { 941 property: "og:image", 942 content: gallery?.items?.filter(isPhotoView)?.[0]?.thumb, 943 }, 944 ]; 945} 946 947function Root(props: Readonly<RootProps<State>>) { 948 const profile = props.ctx.state.profile; 949 const scripts = props.ctx.state.scripts; 950 return ( 951 <html lang="en" class="w-full h-full"> 952 <head> 953 <meta charset="UTF-8" /> 954 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 955 <Meta meta={props.ctx.state.meta} /> 956 {GOATCOUNTER_URL 957 ? ( 958 <script 959 data-goatcounter={GOATCOUNTER_URL} 960 async 961 src="//gc.zgo.at/count.js" 962 /> 963 ) 964 : null} 965 <script src="https://unpkg.com/htmx.org@1.9.10" /> 966 <script src="https://unpkg.com/hyperscript.org@0.9.14" /> 967 <style dangerouslySetInnerHTML={{ __html: CSS }} /> 968 <link rel="stylesheet" href={`/static/styles.css?${cssContentHash}`} /> 969 <link rel="preconnect" href="https://fonts.googleapis.com" /> 970 <link 971 rel="preconnect" 972 href="https://fonts.gstatic.com" 973 crossOrigin="anonymous" 974 /> 975 <link 976 href="https://fonts.googleapis.com/css2?family=Jersey+20&display=swap" 977 rel="stylesheet" 978 /> 979 <link 980 rel="stylesheet" 981 href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 982 preload 983 /> 984 {scripts?.map((file) => <script key={file} src={`/static/${file}`} />)} 985 </head> 986 <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 987 <Layout id="layout" class="dark:border-zinc-800"> 988 <Layout.Nav 989 heading={ 990 <h1 class="font-['Jersey_20'] text-4xl text-zinc-900 dark:text-white"> 991 grain 992 <sub class="bottom-[0.75rem] text-[1rem]">beta</sub> 993 </h1> 994 } 995 profile={profile} 996 class="dark:border-zinc-800" 997 /> 998 <Layout.Content>{props.children}</Layout.Content> 999 </Layout> 1000 </body> 1001 </html> 1002 ); 1003} 1004 1005function Header({ 1006 children, 1007 class: classProp, 1008 ...props 1009}: Readonly< 1010 JSX.HTMLAttributes<HTMLHeadingElement> & { children: ComponentChildren } 1011>) { 1012 return ( 1013 <h1 class={cn("text-xl font-semibold", classProp)} {...props}> 1014 {children} 1015 </h1> 1016 ); 1017} 1018 1019function AvatarButton({ 1020 profile, 1021}: Readonly<{ profile: Un$Typed<ProfileView> }>) { 1022 return ( 1023 <button 1024 type="button" 1025 class="cursor-pointer" 1026 hx-get={`/dialogs/avatar/${profile.handle}`} 1027 hx-trigger="click" 1028 hx-target="body" 1029 hx-swap="afterbegin" 1030 > 1031 <img 1032 src={profile.avatar} 1033 alt={profile.handle} 1034 class="rounded-full object-cover size-16" 1035 /> 1036 </button> 1037 ); 1038} 1039 1040function AvatarDialog({ 1041 profile, 1042}: Readonly<{ profile: Un$Typed<ProfileView> }>) { 1043 return ( 1044 <Dialog> 1045 <div 1046 class="w-[400px] h-[400px] flex flex-col p-4 z-10" 1047 _={Dialog._closeOnClick} 1048 > 1049 <img 1050 src={profile.avatar} 1051 alt={profile.handle} 1052 class="rounded-full w-full h-full object-cover" 1053 /> 1054 </div> 1055 </Dialog> 1056 ); 1057} 1058 1059function Timeline({ items }: Readonly<{ items: TimelineItem[] }>) { 1060 return ( 1061 <div class="px-4 mb-4"> 1062 <div class="my-4"> 1063 <Header>Timeline</Header> 1064 </div> 1065 <ul class="space-y-4 relative"> 1066 {items.map((item) => <TimelineItem item={item} key={item.itemUri} />)} 1067 </ul> 1068 </div> 1069 ); 1070} 1071 1072function TimelineItem({ item }: Readonly<{ item: TimelineItem }>) { 1073 return ( 1074 <li class="space-y-2"> 1075 <div class="bg-zinc-100 dark:bg-zinc-900 w-fit p-2"> 1076 <a 1077 href={profileLink(item.actor.handle)} 1078 class="font-semibold hover:underline" 1079 > 1080 @{item.actor.handle} 1081 </a>{" "} 1082 {item.itemType === "favorite" ? "favorited" : "created"}{" "} 1083 <a 1084 href={galleryLink( 1085 item.gallery.creator.handle, 1086 new AtUri(item.gallery.uri).rkey, 1087 )} 1088 class="font-semibold hover:underline" 1089 > 1090 {(item.gallery.record as Gallery).title} 1091 </a> 1092 <span class="ml-1"> 1093 {formatDistanceStrict(item.createdAt, new Date(), { 1094 addSuffix: true, 1095 })} 1096 </span> 1097 </div> 1098 <a 1099 href={galleryLink( 1100 item.gallery.creator.handle, 1101 new AtUri(item.gallery.uri).rkey, 1102 )} 1103 class="w-fit flex" 1104 > 1105 {item.gallery.items?.filter(isPhotoView).length 1106 ? ( 1107 <div class="flex w-full max-w-md mx-auto aspect-[3/2] overflow-hidden gap-2"> 1108 <div class="w-2/3 h-full"> 1109 <img 1110 src={item.gallery.items?.filter(isPhotoView)[0].thumb} 1111 alt={item.gallery.items?.filter(isPhotoView)[0].alt} 1112 class="w-full h-full object-cover" 1113 /> 1114 </div> 1115 <div class="w-1/3 flex flex-col h-full gap-2"> 1116 <div class="h-1/2"> 1117 {item.gallery.items?.filter(isPhotoView)?.[1] 1118 ? ( 1119 <img 1120 src={item.gallery.items?.filter(isPhotoView)?.[1] 1121 ?.thumb} 1122 alt={item.gallery.items?.filter(isPhotoView)?.[1]?.alt} 1123 class="w-full h-full object-cover" 1124 /> 1125 ) 1126 : ( 1127 <div className="w-full h-full bg-zinc-200 dark:bg-zinc-900" /> 1128 )} 1129 </div> 1130 <div class="h-1/2"> 1131 {item.gallery.items?.filter(isPhotoView)?.[2] 1132 ? ( 1133 <img 1134 src={item.gallery.items?.filter(isPhotoView)?.[2] 1135 ?.thumb} 1136 alt={item.gallery.items?.filter(isPhotoView)?.[2]?.alt} 1137 class="w-full h-full object-cover" 1138 /> 1139 ) 1140 : ( 1141 <div className="w-full h-full bg-zinc-200 dark:bg-zinc-900" /> 1142 )} 1143 </div> 1144 </div> 1145 </div> 1146 ) 1147 : null} 1148 </a> 1149 </li> 1150 ); 1151} 1152 1153function ProfilePage({ 1154 loggedInUserDid, 1155 timelineItems, 1156 profile, 1157 selectedTab, 1158 galleries, 1159}: Readonly<{ 1160 loggedInUserDid?: string; 1161 timelineItems: TimelineItem[]; 1162 profile: Un$Typed<ProfileView>; 1163 selectedTab?: string; 1164 galleries?: GalleryView[]; 1165}>) { 1166 return ( 1167 <div class="px-4 mb-4" id="profile-page"> 1168 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> 1169 <div class="flex flex-col"> 1170 <AvatarButton profile={profile} /> 1171 <p class="text-2xl font-bold">{profile.displayName}</p> 1172 <p class="text-zinc-600 dark:text-zinc-500">@{profile.handle}</p> 1173 <p class="my-2">{profile.description}</p> 1174 </div> 1175 {loggedInUserDid === profile.did 1176 ? ( 1177 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1178 <Button variant="primary" class="w-full sm:w-fit" asChild> 1179 <a href="/upload"> 1180 <i class="fa-solid fa-upload mr-2" /> 1181 Upload 1182 </a> 1183 </Button> 1184 <Button 1185 variant="primary" 1186 type="button" 1187 hx-get="/dialogs/profile" 1188 hx-target="#layout" 1189 hx-swap="afterbegin" 1190 class="w-full sm:w-fit" 1191 > 1192 Edit Profile 1193 </Button> 1194 <Button 1195 variant="primary" 1196 type="button" 1197 class="w-full sm:w-fit" 1198 hx-get="/dialogs/gallery/new" 1199 hx-target="#layout" 1200 hx-swap="afterbegin" 1201 > 1202 Create Gallery 1203 </Button> 1204 </div> 1205 ) 1206 : null} 1207 </div> 1208 <div class="my-4 space-x-2 w-full flex sm:w-fit" role="tablist"> 1209 <button 1210 type="button" 1211 hx-get={profileLink(profile.handle)} 1212 hx-target="body" 1213 hx-swap="outerHTML" 1214 class={cn( 1215 "flex-1 py-2 px-4 cursor-pointer font-semibold", 1216 !selectedTab && "bg-zinc-100 dark:bg-zinc-800 font-semibold", 1217 )} 1218 role="tab" 1219 aria-selected="true" 1220 aria-controls="tab-content" 1221 > 1222 Activity 1223 </button> 1224 <button 1225 type="button" 1226 hx-get={profileLink(profile.handle) + "?tab=galleries"} 1227 hx-target="#profile-page" 1228 hx-swap="outerHTML" 1229 class={cn( 1230 "flex-1 py-2 px-4 cursor-pointer font-semibold", 1231 selectedTab === "galleries" && "bg-zinc-100 dark:bg-zinc-800", 1232 )} 1233 role="tab" 1234 aria-selected="false" 1235 aria-controls="tab-content" 1236 > 1237 Galleries 1238 </button> 1239 </div> 1240 <div id="tab-content" role="tabpanel"> 1241 {!selectedTab 1242 ? ( 1243 <ul class="space-y-4 relative"> 1244 {timelineItems.length 1245 ? ( 1246 timelineItems.map((item) => ( 1247 <TimelineItem item={item} key={item.itemUri} /> 1248 )) 1249 ) 1250 : <li>No activity yet.</li>} 1251 </ul> 1252 ) 1253 : null} 1254 {selectedTab === "galleries" 1255 ? ( 1256 <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"> 1257 {galleries?.length 1258 ? ( 1259 galleries.map((gallery) => ( 1260 <a 1261 href={galleryLink( 1262 gallery.creator.handle, 1263 new AtUri(gallery.uri).rkey, 1264 )} 1265 class="cursor-pointer relative aspect-square" 1266 > 1267 {gallery.items?.length 1268 ? ( 1269 <img 1270 src={gallery.items?.filter(isPhotoView)?.[0]?.thumb} 1271 alt={gallery.items?.filter(isPhotoView)?.[0]?.alt} 1272 class="w-full h-full object-cover" 1273 /> 1274 ) 1275 : ( 1276 <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" /> 1277 )} 1278 <div class="absolute bottom-0 left-0 bg-black/80 text-white p-2"> 1279 {(gallery.record as Gallery).title} 1280 </div> 1281 </a> 1282 )) 1283 ) 1284 : <p>No galleries yet.</p>} 1285 </div> 1286 ) 1287 : null} 1288 </div> 1289 </div> 1290 ); 1291} 1292 1293function UploadPage( 1294 { handle, photos, returnTo }: Readonly< 1295 { handle: string; photos: PhotoView[]; returnTo?: string } 1296 >, 1297) { 1298 return ( 1299 <div class="flex flex-col px-4 pt-4 mb-4 space-y-4"> 1300 {returnTo 1301 ? ( 1302 <a 1303 href={returnTo} 1304 class="hover:underline" 1305 > 1306 <i class="fa-solid fa-arrow-left mr-2" /> 1307 Back to gallery 1308 </a> 1309 ) 1310 : ( 1311 <a href={profileLink(handle)} class="hover:underline"> 1312 <i class="fa-solid fa-arrow-left mr-2" /> 1313 Back to profile 1314 </a> 1315 )} 1316 <Button variant="primary" class="mb-4" asChild> 1317 <label class="w-fit"> 1318 <i class="fa fa-plus"></i> Add photos 1319 <input 1320 class="hidden" 1321 type="file" 1322 multiple 1323 accept="image/*" 1324 _="on change 1325 set fileList to me.files 1326 if fileList.length > 10 1327 alert('You can only upload 10 photos') 1328 halt 1329 end 1330 for file in fileList 1331 make a FormData called fd 1332 fd.append('file', file) 1333 fetch /actions/photo/upload-start with { method:'POST', body:fd } 1334 then put it at the start of #image-preview 1335 then call htmx.process(#image-preview) 1336 end 1337 set me.value to ''" 1338 /> 1339 </label> 1340 </Button> 1341 <div 1342 id="image-preview" 1343 class="w-full h-full grid grid-cols-2 sm:grid-cols-5 gap-2" 1344 > 1345 {photos.map((photo) => ( 1346 <PhotoPreview key={photo.cid} src={photo.thumb} uri={photo.uri} /> 1347 ))} 1348 </div> 1349 </div> 1350 ); 1351} 1352 1353function ProfileDialog({ 1354 profile, 1355 avatarCid, 1356}: Readonly<{ 1357 profile: ProfileView; 1358 avatarCid?: string; 1359}>) { 1360 return ( 1361 <Dialog> 1362 <Dialog.Content class="dark:bg-zinc-950"> 1363 <Dialog.Title>Edit my profile</Dialog.Title> 1364 <div> 1365 <AvatarForm src={profile.avatar} alt={profile.handle} /> 1366 </div> 1367 <form 1368 hx-post="/actions/profile/update" 1369 hx-swap="none" 1370 _="on htmx:afterOnLoad trigger closeModal" 1371 > 1372 <div id="image-input"> 1373 <input type="hidden" name="avatarCid" value={avatarCid} /> 1374 </div> 1375 <div class="mb-4 relative"> 1376 <label htmlFor="displayName">Display Name</label> 1377 <Input 1378 type="text" 1379 required 1380 id="displayName" 1381 name="displayName" 1382 class="dark:bg-zinc-800 dark:text-white" 1383 value={profile.displayName} 1384 /> 1385 </div> 1386 <div class="mb-4 relative"> 1387 <label htmlFor="description">Description</label> 1388 <Textarea 1389 id="description" 1390 name="description" 1391 rows={4} 1392 class="dark:bg-zinc-800 dark:text-white" 1393 > 1394 {profile.description} 1395 </Textarea> 1396 </div> 1397 <Button type="submit" variant="primary" class="w-full"> 1398 Update 1399 </Button> 1400 <Button 1401 variant="secondary" 1402 type="button" 1403 class="w-full" 1404 _={Dialog._closeOnClick} 1405 > 1406 Cancel 1407 </Button> 1408 </form> 1409 </Dialog.Content> 1410 </Dialog> 1411 ); 1412} 1413 1414function AvatarForm({ src, alt }: Readonly<{ src?: string; alt?: string }>) { 1415 return ( 1416 <form 1417 id="avatar-file-form" 1418 hx-post="/actions/avatar/upload-start" 1419 hx-target="#image-preview" 1420 hx-swap="innerHTML" 1421 hx-encoding="multipart/form-data" 1422 hx-trigger="change from:#file" 1423 > 1424 <label htmlFor="file"> 1425 <span class="sr-only">Upload avatar</span> 1426 <div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer"> 1427 <div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 1428 <i class="fa-solid fa-camera text-white text-xs"></i> 1429 </div> 1430 <div id="image-preview" class="w-full h-full"> 1431 {src 1432 ? ( 1433 <img 1434 src={src} 1435 alt={alt} 1436 className="rounded-full w-full h-full object-cover" 1437 /> 1438 ) 1439 : null} 1440 </div> 1441 </div> 1442 <input 1443 class="hidden" 1444 type="file" 1445 id="file" 1446 name="file" 1447 accept="image/*" 1448 /> 1449 </label> 1450 </form> 1451 ); 1452} 1453 1454function GalleryPage({ 1455 gallery, 1456 favs = [], 1457 currentUserDid, 1458}: Readonly<{ 1459 gallery: GalleryView; 1460 favs: WithBffMeta<Favorite>[]; 1461 currentUserDid?: string; 1462}>) { 1463 const isCreator = currentUserDid === gallery.creator.did; 1464 const isLoggedIn = !!currentUserDid; 1465 return ( 1466 <div class="px-4"> 1467 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> 1468 <div class="flex flex-col space-y-1 mb-4"> 1469 <h1 class="font-bold text-2xl"> 1470 {(gallery.record as Gallery).title} 1471 </h1> 1472 <div> 1473 Gallery by{" "} 1474 <a 1475 href={profileLink(gallery.creator.handle)} 1476 class="hover:underline" 1477 > 1478 <span class="font-semibold">{gallery.creator.displayName}</span> 1479 {" "} 1480 <span class="text-zinc-600 dark:text-zinc-500"> 1481 @{gallery.creator.handle} 1482 </span> 1483 </a> 1484 </div> 1485 <p>{(gallery.record as Gallery).description}</p> 1486 </div> 1487 {isLoggedIn && isCreator 1488 ? ( 1489 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1490 <Button 1491 hx-get={`/dialogs/photo-select/${new AtUri(gallery.uri).rkey}`} 1492 hx-target="#layout" 1493 hx-swap="afterbegin" 1494 variant="primary" 1495 class="self-start w-full sm:w-fit" 1496 > 1497 Add photos 1498 </Button> 1499 <Button 1500 variant="primary" 1501 class="self-start w-full sm:w-fit" 1502 hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`} 1503 hx-target="#layout" 1504 hx-swap="afterbegin" 1505 > 1506 Edit 1507 </Button> 1508 </div> 1509 ) 1510 : null} 1511 {!isCreator 1512 ? ( 1513 <FavoriteButton 1514 currentUserDid={currentUserDid} 1515 favs={favs} 1516 galleryUri={gallery.uri} 1517 /> 1518 ) 1519 : null} 1520 </div> 1521 <div 1522 id="masonry-container" 1523 class="h-0 overflow-hidden relative mx-auto w-full" 1524 _="on load or htmx:afterSettle call computeMasonry()" 1525 > 1526 {gallery.items?.filter(isPhotoView)?.length 1527 ? gallery?.items 1528 ?.filter(isPhotoView) 1529 ?.map((photo) => ( 1530 <PhotoButton 1531 key={photo.cid} 1532 photo={photo} 1533 gallery={gallery} 1534 isCreator={isCreator} 1535 isLoggedIn={isLoggedIn} 1536 /> 1537 )) 1538 : null} 1539 </div> 1540 </div> 1541 ); 1542} 1543 1544function PhotoButton({ 1545 photo, 1546 gallery, 1547 isCreator, 1548 isLoggedIn, 1549}: Readonly<{ 1550 photo: PhotoView; 1551 gallery: GalleryView; 1552 isCreator: boolean; 1553 isLoggedIn: boolean; 1554}>) { 1555 return ( 1556 <button 1557 id={`photo-${new AtUri(photo.uri).rkey}`} 1558 type="button" 1559 hx-get={photoDialogLink(gallery, photo)} 1560 hx-trigger="click" 1561 hx-target="#layout" 1562 hx-swap="afterbegin" 1563 class="masonry-tile absolute cursor-pointer" 1564 data-width={photo.aspectRatio?.width} 1565 data-height={photo.aspectRatio?.height} 1566 > 1567 {isLoggedIn && isCreator 1568 ? <AltTextButton galleryUri={gallery.uri} cid={photo.cid} /> 1569 : null} 1570 <img 1571 src={photo.fullsize} 1572 alt={photo.alt} 1573 class="w-full h-full object-cover" 1574 /> 1575 {!isCreator && photo.alt 1576 ? ( 1577 <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]"> 1578 ALT 1579 </div> 1580 ) 1581 : null} 1582 </button> 1583 ); 1584} 1585 1586function FavoriteButton({ 1587 currentUserDid, 1588 favs = [], 1589 galleryUri, 1590}: Readonly<{ 1591 currentUserDid?: string; 1592 favs: WithBffMeta<Favorite>[]; 1593 galleryUri: string; 1594}>) { 1595 const favUri = favs.find((s) => currentUserDid === s.did)?.uri; 1596 return ( 1597 <Button 1598 variant="primary" 1599 class="self-start w-full sm:w-fit" 1600 type="button" 1601 hx-post={`/actions/favorite?galleryUri=${galleryUri}${ 1602 favUri ? "&favUri=" + favUri : "" 1603 }`} 1604 hx-target="this" 1605 hx-swap="outerHTML" 1606 > 1607 <i class={cn("fa-heart", favUri ? "fa-solid" : "fa-regular")}></i>{" "} 1608 {favs.length} 1609 </Button> 1610 ); 1611} 1612 1613function GalleryCreateEditDialog({ 1614 gallery, 1615}: Readonly<{ gallery?: GalleryView | null }>) { 1616 return ( 1617 <Dialog id="gallery-dialog" class="z-30"> 1618 <Dialog.Content class="dark:bg-zinc-950"> 1619 <Dialog.Title> 1620 {gallery ? "Edit gallery" : "Create a new gallery"} 1621 </Dialog.Title> 1622 <form 1623 id="gallery-form" 1624 class="max-w-xl" 1625 hx-post={`/actions/create-edit${ 1626 gallery ? "?uri=" + gallery?.uri : "" 1627 }`} 1628 hx-swap="none" 1629 _="on htmx:afterOnLoad 1630 if event.detail.xhr.status != 200 1631 alert('Error: ' + event.detail.xhr.responseText)" 1632 > 1633 <div class="mb-4 relative"> 1634 <label htmlFor="title">Gallery name</label> 1635 <Input 1636 type="text" 1637 id="title" 1638 name="title" 1639 class="dark:bg-zinc-800 dark:text-white" 1640 required 1641 value={(gallery?.record as Gallery)?.title} 1642 autofocus 1643 /> 1644 </div> 1645 <div class="mb-2 relative"> 1646 <label htmlFor="description">Description</label> 1647 <Textarea 1648 id="description" 1649 name="description" 1650 rows={4} 1651 class="dark:bg-zinc-800 dark:text-white" 1652 > 1653 {(gallery?.record as Gallery)?.description} 1654 </Textarea> 1655 </div> 1656 </form> 1657 <div class="max-w-xl"> 1658 <input 1659 type="button" 1660 name="galleryUri" 1661 value={gallery?.uri} 1662 class="hidden" 1663 /> 1664 </div> 1665 <form 1666 id="delete-form" 1667 hx-post={`/actions/gallery/delete?uri=${gallery?.uri}`} 1668 > 1669 <input type="hidden" name="uri" value={gallery?.uri} /> 1670 </form> 1671 <div class="flex flex-col gap-2 mt-2"> 1672 <Button 1673 variant="primary" 1674 form="gallery-form" 1675 type="submit" 1676 class="w-full" 1677 > 1678 {gallery ? "Update gallery" : "Create gallery"} 1679 </Button> 1680 {gallery 1681 ? ( 1682 <Button 1683 variant="destructive" 1684 form="delete-form" 1685 type="submit" 1686 class="w-full" 1687 > 1688 Delete gallery 1689 </Button> 1690 ) 1691 : null} 1692 <Button 1693 variant="secondary" 1694 type="button" 1695 class="w-full" 1696 _={Dialog._closeOnClick} 1697 > 1698 Cancel 1699 </Button> 1700 </div> 1701 </Dialog.Content> 1702 </Dialog> 1703 ); 1704} 1705 1706function PhotoPreview({ 1707 src, 1708 uri, 1709}: Readonly<{ 1710 src: string; 1711 uri?: string; 1712}>) { 1713 return ( 1714 <div class="relative aspect-square dark:bg-zinc-900"> 1715 {uri 1716 ? ( 1717 <button 1718 type="button" 1719 hx-delete={`/actions/photo/${new AtUri(uri).rkey}`} 1720 class="bg-zinc-950 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center" 1721 _="on htmx:afterOnLoad remove me.parentNode" 1722 > 1723 <i class="fas fa-close text-white"></i> 1724 </button> 1725 ) 1726 : null} 1727 <img 1728 src={src} 1729 alt="" 1730 data-state={uri ? "complete" : "pending"} 1731 class="absolute inset-0 w-full h-full object-contain data-[state=pending]:opacity-50" 1732 /> 1733 </div> 1734 ); 1735} 1736 1737function AltTextButton({ 1738 galleryUri, 1739 cid, 1740}: Readonly<{ galleryUri: string; cid: string }>) { 1741 return ( 1742 <div 1743 class="bg-zinc-950 dark:bg-zinc-900 py-[1px] px-[3px] absolute top-1 left-1 sm:top-1 sm:left-1 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" 1744 hx-get={`/dialogs/image-alt?galleryUri=${galleryUri}&imageCid=${cid}`} 1745 hx-trigger="click" 1746 hx-target="#layout" 1747 hx-swap="afterbegin" 1748 _="on click halt" 1749 > 1750 <i class="fas fa-plus text-[10px] mr-1"></i> ALT 1751 </div> 1752 ); 1753} 1754 1755function PhotoDialog({ 1756 gallery, 1757 image, 1758 nextImage, 1759 prevImage, 1760}: Readonly<{ 1761 gallery: GalleryView; 1762 image: PhotoView; 1763 nextImage?: PhotoView; 1764 prevImage?: PhotoView; 1765}>) { 1766 return ( 1767 <Dialog id="photo-dialog" class="bg-zinc-950 z-30"> 1768 {nextImage 1769 ? ( 1770 <div 1771 hx-get={photoDialogLink(gallery, nextImage)} 1772 hx-trigger="keyup[key=='ArrowRight'] from:body, swipeleft from:body" 1773 hx-target="#photo-dialog" 1774 hx-swap="innerHTML" 1775 /> 1776 ) 1777 : null} 1778 {prevImage 1779 ? ( 1780 <div 1781 hx-get={photoDialogLink(gallery, prevImage)} 1782 hx-trigger="keyup[key=='ArrowLeft'] from:body, swiperight from:body" 1783 hx-target="#photo-dialog" 1784 hx-swap="innerHTML" 1785 /> 1786 ) 1787 : null} 1788 <div 1789 class="flex flex-col w-5xl h-[calc(100vh-100px)] sm:h-screen z-20" 1790 _={Dialog._closeOnClick} 1791 > 1792 <div class="flex flex-col p-4 z-20 flex-1 relative"> 1793 <img 1794 src={image.fullsize} 1795 alt={image.alt} 1796 class="absolute inset-0 w-full h-full object-contain" 1797 /> 1798 </div> 1799 {image.alt 1800 ? ( 1801 <div class="px-4 sm:px-0 py-4 bg-black text-white text-left"> 1802 {image.alt} 1803 </div> 1804 ) 1805 : null} 1806 </div> 1807 </Dialog> 1808 ); 1809} 1810 1811function PhotoAltDialog({ 1812 photo, 1813 galleryUri, 1814}: Readonly<{ 1815 photo: PhotoView; 1816 galleryUri: string; 1817}>) { 1818 return ( 1819 <Dialog id="photo-alt-dialog" class="z-30"> 1820 <Dialog.Content class="dark:bg-zinc-950"> 1821 <Dialog.Title>Add alt text</Dialog.Title> 1822 <div class="aspect-square relative"> 1823 <img 1824 src={photo.fullsize} 1825 alt={photo.alt} 1826 class="absolute inset-0 w-full h-full object-contain" 1827 /> 1828 </div> 1829 <form 1830 hx-put={`/actions/photo/${new AtUri(photo.uri).rkey}`} 1831 _="on htmx:afterOnLoad trigger closeDialog" 1832 > 1833 <input type="hidden" name="galleryUri" value={galleryUri} /> 1834 <input type="hidden" name="cid" value={photo.cid} /> 1835 <div class="my-2"> 1836 <label htmlFor="alt">Descriptive alt text</label> 1837 <Textarea 1838 id="alt" 1839 name="alt" 1840 rows={4} 1841 defaultValue={photo.alt} 1842 placeholder="Alt text" 1843 autoFocus 1844 class="dark:bg-zinc-800 dark:text-white" 1845 /> 1846 </div> 1847 <div class="w-full flex flex-col gap-2 mt-2"> 1848 <Button type="submit" variant="primary" class="w-full"> 1849 Save 1850 </Button> 1851 <Dialog.Close class="w-full">Cancel</Dialog.Close> 1852 </div> 1853 </form> 1854 </Dialog.Content> 1855 </Dialog> 1856 ); 1857} 1858 1859function PhotoSelectDialog({ 1860 galleryUri, 1861 itemUris, 1862 photos, 1863}: Readonly<{ 1864 galleryUri: string; 1865 itemUris: string[]; 1866 photos: PhotoView[]; 1867}>) { 1868 return ( 1869 <Dialog id="photo-select-dialog" class="z-30"> 1870 <Dialog.Content class="w-full max-w-5xl dark:bg-zinc-950 sm:min-h-screen flex flex-col"> 1871 <Dialog.Title>Add photos</Dialog.Title> 1872 {photos.length 1873 ? ( 1874 <p class="my-2 text-center"> 1875 Choose photos to add/remove from your gallery. Click close when 1876 done. 1877 </p> 1878 ) 1879 : null} 1880 {photos.length 1881 ? ( 1882 <div class="grid grid-cols-2 sm:grid-cols-3 gap-4 my-4 flex-1"> 1883 {photos.map((photo) => ( 1884 <PhotoSelectButton 1885 key={photo.cid} 1886 galleryUri={galleryUri} 1887 itemUris={itemUris} 1888 photo={photo} 1889 /> 1890 ))} 1891 </div> 1892 ) 1893 : ( 1894 <div class="flex-1 flex justify-center items-center my-30"> 1895 <p> 1896 No photos yet.{" "} 1897 <a 1898 href={`/upload?returnTo=${new AtUri(galleryUri).rkey}`} 1899 class="hover:underline font-semibold text-sky-500" 1900 > 1901 Upload 1902 </a>{" "} 1903 photos and return to add. 1904 </p> 1905 </div> 1906 )} 1907 <div class="w-full flex flex-col gap-2 mt-2"> 1908 <Dialog.Close class="w-full">Close</Dialog.Close> 1909 </div> 1910 </Dialog.Content> 1911 </Dialog> 1912 ); 1913} 1914 1915function PhotoSelectButton({ 1916 galleryUri, 1917 itemUris, 1918 photo, 1919}: Readonly<{ 1920 galleryUri: string; 1921 itemUris: string[]; 1922 photo: PhotoView; 1923}>) { 1924 return ( 1925 <button 1926 hx-put={`/actions/gallery/${new AtUri(galleryUri).rkey}/${ 1927 itemUris.includes(photo.uri) ? "remove-photo" : "add-photo" 1928 }/${new AtUri(photo.uri).rkey}`} 1929 hx-swap="outerHTML" 1930 type="button" 1931 data-added={itemUris.includes(photo.uri) ? "true" : "false"} 1932 class="group cursor-pointer relative aspect-square data-[added=true]:ring-2 ring-sky-500 disabled:opacity-50" 1933 _={`on htmx:beforeRequest add @disabled to me 1934 then on htmx:afterOnLoad 1935 remove @disabled from me 1936 if @data-added == 'true' 1937 set @data-added to 'false' 1938 remove #photo-${new AtUri(photo.uri).rkey} 1939 else 1940 set @data-added to 'true' 1941 end`} 1942 > 1943 <div class="hidden group-data-[added=true]:block absolute top-2 right-2"> 1944 <i class="fa-check fa-solid text-sky-500 z-10" /> 1945 </div> 1946 <img 1947 src={photo.fullsize} 1948 alt={photo.alt} 1949 class="absolute inset-0 w-full h-full object-contain" 1950 /> 1951 </button> 1952 ); 1953} 1954 1955function getActorProfile(did: string, ctx: BffContext) { 1956 const actor = ctx.indexService.getActor(did); 1957 if (!actor) return null; 1958 const profileRecord = ctx.indexService.getRecord<WithBffMeta<Profile>>( 1959 `at://${did}/social.grain.actor.profile/self`, 1960 ); 1961 return profileRecord ? profileToView(profileRecord, actor.handle) : null; 1962} 1963 1964function galleryToView( 1965 record: WithBffMeta<Gallery>, 1966 creator: Un$Typed<ProfileView>, 1967 items: Photo[], 1968): Un$Typed<GalleryView> { 1969 return { 1970 uri: record.uri, 1971 cid: record.cid, 1972 creator, 1973 record, 1974 items: items 1975 ?.map((item) => itemToView(record.did, item)) 1976 .filter(isPhotoView), 1977 indexedAt: record.indexedAt, 1978 }; 1979} 1980 1981function itemToView( 1982 did: string, 1983 item: 1984 | WithBffMeta<Photo> 1985 | { 1986 $type: string; 1987 }, 1988): Un$Typed<PhotoView> | undefined { 1989 if (isPhoto(item)) { 1990 return photoToView(did, item); 1991 } 1992 return undefined; 1993} 1994 1995function photoToView( 1996 did: string, 1997 photo: WithBffMeta<Photo>, 1998): $Typed<PhotoView> { 1999 return { 2000 $type: "social.grain.photo.defs#photoView", 2001 uri: photo.uri, 2002 cid: photo.photo.ref.toString(), 2003 thumb: 2004 `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${photo.photo.ref.toString()}@webp`, 2005 fullsize: 2006 `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${photo.photo.ref.toString()}@webp`, 2007 alt: photo.alt, 2008 aspectRatio: photo.aspectRatio, 2009 }; 2010} 2011 2012function profileToView( 2013 record: WithBffMeta<Profile>, 2014 handle: string, 2015): Un$Typed<ProfileView> { 2016 return { 2017 did: record.did, 2018 handle, 2019 displayName: record.displayName, 2020 description: record.description, 2021 avatar: record?.avatar 2022 ? `https://cdn.bsky.app/img/feed_thumbnail/plain/${record.did}/${record.avatar.ref.toString()}` 2023 : undefined, 2024 }; 2025} 2026 2027function profileLink(handle: string) { 2028 return `/profile/${handle}`; 2029} 2030 2031function galleryLink(handle: string, galleryRkey: string) { 2032 return `/profile/${handle}/${galleryRkey}`; 2033} 2034 2035function photoDialogLink(gallery: GalleryView, image: PhotoView) { 2036 return `/dialogs/image?galleryUri=${gallery.uri}&imageCid=${image.cid}`; 2037} 2038 2039async function onSignedIn({ actor, ctx }: onSignedInArgs) { 2040 await ctx.backfillCollections( 2041 [actor.did], 2042 [...ctx.cfg.collections!, "app.bsky.actor.profile"], 2043 ); 2044 2045 const profileResults = ctx.indexService.getRecords<Profile>( 2046 "social.grain.actor.profile", 2047 { 2048 where: [{ field: "did", equals: actor.did }], 2049 }, 2050 ); 2051 2052 const profile = profileResults.items[0]; 2053 2054 if (profile) { 2055 console.log("Profile already exists"); 2056 return `/profile/${actor.handle}`; 2057 } 2058 2059 const bskyProfileResults = ctx.indexService.getRecords<BskyProfile>( 2060 "app.bsky.actor.profile", 2061 { 2062 where: [{ field: "did", equals: actor.did }], 2063 }, 2064 ); 2065 2066 const bskyProfile = bskyProfileResults.items[0]; 2067 2068 if (!bskyProfile) { 2069 console.error("Failed to get profile"); 2070 return; 2071 } 2072 2073 await ctx.createRecord<Profile>( 2074 "social.grain.actor.profile", 2075 { 2076 displayName: bskyProfile.displayName ?? undefined, 2077 description: bskyProfile.description ?? undefined, 2078 avatar: bskyProfile.avatar ?? undefined, 2079 createdAt: new Date().toISOString(), 2080 }, 2081 true, 2082 ); 2083 2084 return "/onboard"; 2085} 2086 2087function uploadStart( 2088 routePrefix: string, 2089 cb: (params: { uploadId: string; dataUrl?: string; cid?: string }) => VNode, 2090): RouteHandler { 2091 return async (req, _params, ctx) => { 2092 requireAuth(ctx); 2093 const formData = await req.formData(); 2094 const file = formData.get("file") as File; 2095 if (!file) { 2096 return new Response("No file", { status: 400 }); 2097 } 2098 const dataUrl = await compressImageForPreview(file); 2099 const uploadId = ctx.uploadBlob({ 2100 file, 2101 dataUrl, 2102 }); 2103 return ctx.html( 2104 <div 2105 id={`upload-id-${uploadId}`} 2106 hx-trigger="done" 2107 hx-get={`/actions/${routePrefix}/upload-done?uploadId=${uploadId}`} 2108 hx-target="this" 2109 hx-swap="outerHTML" 2110 class="h-full w-full" 2111 > 2112 <div 2113 hx-get={`/actions/${routePrefix}/upload-check-status?uploadId=${uploadId}`} 2114 hx-trigger="every 600ms" 2115 hx-target="this" 2116 hx-swap="innerHTML" 2117 class="h-full w-full" 2118 > 2119 {cb({ uploadId, dataUrl })} 2120 </div> 2121 </div>, 2122 ); 2123 }; 2124} 2125 2126function uploadCheckStatus( 2127 cb: (params: { uploadId: string; dataUrl: string; cid?: string }) => VNode, 2128): RouteHandler { 2129 return (req, _params, ctx) => { 2130 requireAuth(ctx); 2131 const url = new URL(req.url); 2132 const searchParams = new URLSearchParams(url.search); 2133 const uploadId = searchParams.get("uploadId"); 2134 if (!uploadId) return ctx.next(); 2135 const meta = ctx.blobMetaCache.get(uploadId); 2136 if (!meta?.dataUrl) return ctx.next(); 2137 return ctx.html( 2138 cb({ uploadId, dataUrl: meta.dataUrl }), 2139 meta.blobRef ? { "HX-Trigger": "done" } : undefined, 2140 ); 2141 }; 2142} 2143 2144function avatarUploadDone( 2145 cb: (params: { dataUrl: string; cid: string }) => VNode, 2146): RouteHandler { 2147 return (req, _params, ctx) => { 2148 requireAuth(ctx); 2149 const url = new URL(req.url); 2150 const searchParams = new URLSearchParams(url.search); 2151 const uploadId = searchParams.get("uploadId"); 2152 if (!uploadId) return ctx.next(); 2153 const meta = ctx.blobMetaCache.get(uploadId); 2154 if (!meta?.dataUrl || !meta?.blobRef) return ctx.next(); 2155 return ctx.html( 2156 cb({ dataUrl: meta.dataUrl, cid: meta.blobRef.ref.toString() }), 2157 ); 2158 }; 2159} 2160 2161function photoUploadDone( 2162 cb: (params: { dataUrl: string; uri: string }) => VNode, 2163): RouteHandler { 2164 return async (req, _params, ctx) => { 2165 requireAuth(ctx); 2166 const url = new URL(req.url); 2167 const searchParams = new URLSearchParams(url.search); 2168 const uploadId = searchParams.get("uploadId"); 2169 if (!uploadId) return ctx.next(); 2170 const meta = ctx.blobMetaCache.get(uploadId); 2171 if (!meta?.dataUrl || !meta?.blobRef) return ctx.next(); 2172 const photoUri = await ctx.createRecord<Photo>("social.grain.photo", { 2173 photo: meta.blobRef, 2174 aspectRatio: meta.dimensions?.width && meta.dimensions?.height 2175 ? { 2176 width: meta.dimensions.width, 2177 height: meta.dimensions.height, 2178 } 2179 : undefined, 2180 alt: "", 2181 createdAt: new Date().toISOString(), 2182 }); 2183 return ctx.html(cb({ dataUrl: meta.dataUrl, uri: photoUri })); 2184 }; 2185} 2186 2187function photoUploadRoutes(): BffMiddleware[] { 2188 return [ 2189 route( 2190 `/actions/photo/upload-start`, 2191 ["POST"], 2192 uploadStart( 2193 "photo", 2194 ({ dataUrl }) => <PhotoPreview src={dataUrl ?? ""} />, 2195 ), 2196 ), 2197 route( 2198 `/actions/photo/upload-check-status`, 2199 ["GET"], 2200 uploadCheckStatus(({ uploadId, dataUrl }) => ( 2201 <> 2202 <input type="hidden" name="uploadId" value={uploadId} /> 2203 <PhotoPreview src={dataUrl} /> 2204 </> 2205 )), 2206 ), 2207 route( 2208 `/actions/photo/upload-done`, 2209 ["GET"], 2210 photoUploadDone(({ dataUrl, uri }) => ( 2211 <PhotoPreview src={dataUrl} uri={uri} /> 2212 )), 2213 ), 2214 ]; 2215} 2216 2217function avatarUploadRoutes(): BffMiddleware[] { 2218 return [ 2219 route( 2220 `/actions/avatar/upload-start`, 2221 ["POST"], 2222 uploadStart("avatar", ({ dataUrl }) => ( 2223 <img 2224 src={dataUrl} 2225 alt="" 2226 data-state="pending" 2227 class="rounded-full w-full h-full object-cover data-[state=pending]:opacity-50" 2228 /> 2229 )), 2230 ), 2231 route( 2232 `/actions/avatar/upload-check-status`, 2233 ["GET"], 2234 uploadCheckStatus(({ uploadId, dataUrl, cid }) => ( 2235 <> 2236 <input type="hidden" name="uploadId" value={uploadId} /> 2237 <img 2238 src={dataUrl} 2239 alt="" 2240 data-state={cid ? "complete" : "pending"} 2241 class="rounded-full w-full h-full object-cover data-[state=pending]:opacity-50" 2242 /> 2243 </> 2244 )), 2245 ), 2246 route( 2247 `/actions/avatar/upload-done`, 2248 ["GET"], 2249 avatarUploadDone(({ dataUrl, cid }) => ( 2250 <> 2251 <div hx-swap-oob="innerHTML:#image-input"> 2252 <input type="hidden" name="avatarCid" value={cid} /> 2253 </div> 2254 <img 2255 src={dataUrl} 2256 alt="" 2257 class="rounded-full w-full h-full object-cover" 2258 /> 2259 </> 2260 )), 2261 ), 2262 ]; 2263}