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