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