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