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