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