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