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