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