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