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