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