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