+6
-16
__generated__/index.ts
+6
-16
__generated__/index.ts
···
120
120
121
121
export class SocialGrainNS {
122
122
_server: Server
123
-
v0: SocialGrainV0NS
124
-
125
-
constructor(server: Server) {
126
-
this._server = server
127
-
this.v0 = new SocialGrainV0NS(server)
128
-
}
129
-
}
130
-
131
-
export class SocialGrainV0NS {
132
-
_server: Server
133
-
gallery: SocialGrainV0GalleryNS
134
-
actor: SocialGrainV0ActorNS
123
+
gallery: SocialGrainGalleryNS
124
+
actor: SocialGrainActorNS
135
125
136
126
constructor(server: Server) {
137
127
this._server = server
138
-
this.gallery = new SocialGrainV0GalleryNS(server)
139
-
this.actor = new SocialGrainV0ActorNS(server)
128
+
this.gallery = new SocialGrainGalleryNS(server)
129
+
this.actor = new SocialGrainActorNS(server)
140
130
}
141
131
}
142
132
143
-
export class SocialGrainV0GalleryNS {
133
+
export class SocialGrainGalleryNS {
144
134
_server: Server
145
135
146
136
constructor(server: Server) {
···
148
138
}
149
139
}
150
140
151
-
export class SocialGrainV0ActorNS {
141
+
export class SocialGrainActorNS {
152
142
_server: Server
153
143
154
144
constructor(server: Server) {
+132
-78
__generated__/lexicons.ts
+132
-78
__generated__/lexicons.ts
···
2270
2270
},
2271
2271
},
2272
2272
},
2273
-
SocialGrainV0GalleryDefs: {
2273
+
SocialGrainDefs: {
2274
2274
lexicon: 1,
2275
-
id: 'social.grain.v0.gallery.defs',
2275
+
id: 'social.grain.defs',
2276
2276
defs: {
2277
2277
aspectRatio: {
2278
2278
type: 'object',
···
2290
2290
},
2291
2291
},
2292
2292
},
2293
+
},
2294
+
},
2295
+
SocialGrainGalleryItem: {
2296
+
lexicon: 1,
2297
+
id: 'social.grain.gallery.item',
2298
+
defs: {
2299
+
main: {
2300
+
type: 'record',
2301
+
key: 'tid',
2302
+
record: {
2303
+
type: 'object',
2304
+
required: ['createdAt', 'gallery', 'item'],
2305
+
properties: {
2306
+
createdAt: {
2307
+
type: 'string',
2308
+
format: 'datetime',
2309
+
},
2310
+
gallery: {
2311
+
type: 'string',
2312
+
format: 'at-uri',
2313
+
},
2314
+
item: {
2315
+
type: 'string',
2316
+
format: 'at-uri',
2317
+
},
2318
+
},
2319
+
},
2320
+
},
2321
+
},
2322
+
},
2323
+
SocialGrainGalleryDefs: {
2324
+
lexicon: 1,
2325
+
id: 'social.grain.gallery.defs',
2326
+
defs: {
2293
2327
galleryView: {
2294
2328
type: 'object',
2295
2329
required: ['uri', 'cid', 'creator', 'record', 'indexedAt'],
···
2304
2338
},
2305
2339
creator: {
2306
2340
type: 'ref',
2307
-
ref: 'lex:social.grain.v0.actor.defs#profileView',
2341
+
ref: 'lex:social.grain.actor.defs#profileView',
2308
2342
},
2309
2343
record: {
2310
2344
type: 'unknown',
2311
2345
},
2312
-
images: {
2346
+
items: {
2313
2347
type: 'array',
2314
2348
items: {
2315
-
type: 'ref',
2316
-
ref: 'lex:social.grain.v0.gallery.defs#viewImage',
2349
+
type: 'union',
2350
+
refs: ['lex:social.grain.photo.defs#photoView'],
2317
2351
},
2318
2352
},
2319
2353
indexedAt: {
···
2322
2356
},
2323
2357
},
2324
2358
},
2325
-
image: {
2326
-
type: 'object',
2327
-
required: ['image', 'alt'],
2328
-
properties: {
2329
-
image: {
2330
-
type: 'blob',
2331
-
accept: ['image/*'],
2332
-
maxSize: 1000000,
2333
-
},
2334
-
alt: {
2335
-
type: 'string',
2336
-
description:
2337
-
'Alt text description of the image, for accessibility.',
2338
-
},
2339
-
aspectRatio: {
2340
-
type: 'ref',
2341
-
ref: 'lex:social.grain.v0.gallery.defs#aspectRatio',
2342
-
},
2343
-
},
2344
-
},
2345
-
viewImage: {
2346
-
type: 'object',
2347
-
required: ['cid', 'thumb', 'fullsize', 'alt'],
2348
-
properties: {
2349
-
cid: {
2350
-
type: 'string',
2351
-
format: 'cid',
2352
-
},
2353
-
thumb: {
2354
-
type: 'string',
2355
-
format: 'uri',
2356
-
description:
2357
-
'Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View.',
2358
-
},
2359
-
fullsize: {
2360
-
type: 'string',
2361
-
format: 'uri',
2362
-
description:
2363
-
'Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View.',
2364
-
},
2365
-
alt: {
2366
-
type: 'string',
2367
-
description:
2368
-
'Alt text description of the image, for accessibility.',
2369
-
},
2370
-
aspectRatio: {
2371
-
type: 'ref',
2372
-
ref: 'lex:social.grain.v0.gallery.defs#aspectRatio',
2373
-
},
2374
-
},
2375
-
},
2376
2359
},
2377
2360
},
2378
-
SocialGrainV0Gallery: {
2361
+
SocialGrainGallery: {
2379
2362
lexicon: 1,
2380
-
id: 'social.grain.v0.gallery',
2363
+
id: 'social.grain.gallery',
2381
2364
defs: {
2382
2365
main: {
2383
2366
type: 'record',
···
2394
2377
type: 'string',
2395
2378
maxLength: 1000,
2396
2379
},
2397
-
images: {
2398
-
type: 'array',
2399
-
items: {
2400
-
type: 'ref',
2401
-
ref: 'lex:social.grain.v0.gallery.defs#image',
2402
-
},
2403
-
maxLength: 10,
2404
-
},
2405
2380
createdAt: {
2406
2381
type: 'string',
2407
2382
format: 'datetime',
···
2411
2386
},
2412
2387
},
2413
2388
},
2414
-
SocialGrainV0GalleryStar: {
2389
+
SocialGrainFavorite: {
2415
2390
lexicon: 1,
2416
-
id: 'social.grain.v0.gallery.star',
2391
+
id: 'social.grain.favorite',
2417
2392
defs: {
2418
2393
main: {
2419
2394
type: 'record',
···
2435
2410
},
2436
2411
},
2437
2412
},
2438
-
SocialGrainV0ActorDefs: {
2413
+
SocialGrainActorDefs: {
2439
2414
lexicon: 1,
2440
-
id: 'social.grain.v0.actor.defs',
2415
+
id: 'social.grain.actor.defs',
2441
2416
defs: {
2442
2417
profileView: {
2443
2418
type: 'object',
···
2473
2448
},
2474
2449
},
2475
2450
},
2476
-
SocialGrainV0ActorProfile: {
2451
+
SocialGrainActorProfile: {
2477
2452
lexicon: 1,
2478
-
id: 'social.grain.v0.actor.profile',
2453
+
id: 'social.grain.actor.profile',
2479
2454
defs: {
2480
2455
main: {
2481
2456
type: 'record',
···
2501
2476
"Small image to be displayed next to posts from account. AKA, 'profile picture'",
2502
2477
accept: ['image/png', 'image/jpeg'],
2503
2478
maxSize: 1000000,
2479
+
},
2480
+
createdAt: {
2481
+
type: 'string',
2482
+
format: 'datetime',
2483
+
},
2484
+
},
2485
+
},
2486
+
},
2487
+
},
2488
+
},
2489
+
SocialGrainPhotoDefs: {
2490
+
lexicon: 1,
2491
+
id: 'social.grain.photo.defs',
2492
+
defs: {
2493
+
photoView: {
2494
+
type: 'object',
2495
+
required: ['uri', 'cid', 'thumb', 'fullsize', 'alt'],
2496
+
properties: {
2497
+
uri: {
2498
+
type: 'string',
2499
+
format: 'at-uri',
2500
+
},
2501
+
cid: {
2502
+
type: 'string',
2503
+
format: 'cid',
2504
+
},
2505
+
thumb: {
2506
+
type: 'string',
2507
+
format: 'uri',
2508
+
description:
2509
+
'Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View.',
2510
+
},
2511
+
fullsize: {
2512
+
type: 'string',
2513
+
format: 'uri',
2514
+
description:
2515
+
'Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View.',
2516
+
},
2517
+
alt: {
2518
+
type: 'string',
2519
+
description:
2520
+
'Alt text description of the image, for accessibility.',
2521
+
},
2522
+
aspectRatio: {
2523
+
type: 'ref',
2524
+
ref: 'lex:social.grain.defs#aspectRatio',
2525
+
},
2526
+
},
2527
+
},
2528
+
},
2529
+
},
2530
+
SocialGrainPhoto: {
2531
+
lexicon: 1,
2532
+
id: 'social.grain.photo',
2533
+
defs: {
2534
+
main: {
2535
+
type: 'record',
2536
+
key: 'tid',
2537
+
record: {
2538
+
type: 'object',
2539
+
required: ['photo', 'alt'],
2540
+
properties: {
2541
+
photo: {
2542
+
type: 'blob',
2543
+
accept: ['image/*'],
2544
+
maxSize: 1000000,
2545
+
},
2546
+
alt: {
2547
+
type: 'string',
2548
+
description:
2549
+
'Alt text description of the image, for accessibility.',
2550
+
},
2551
+
aspectRatio: {
2552
+
type: 'ref',
2553
+
ref: 'lex:social.grain.defs#aspectRatio',
2504
2554
},
2505
2555
createdAt: {
2506
2556
type: 'string',
···
2758
2808
AppBskyActorDefs: 'app.bsky.actor.defs',
2759
2809
AppBskyActorProfile: 'app.bsky.actor.profile',
2760
2810
AppBskyLabelerDefs: 'app.bsky.labeler.defs',
2761
-
SocialGrainV0GalleryDefs: 'social.grain.v0.gallery.defs',
2762
-
SocialGrainV0Gallery: 'social.grain.v0.gallery',
2763
-
SocialGrainV0GalleryStar: 'social.grain.v0.gallery.star',
2764
-
SocialGrainV0ActorDefs: 'social.grain.v0.actor.defs',
2765
-
SocialGrainV0ActorProfile: 'social.grain.v0.actor.profile',
2811
+
SocialGrainDefs: 'social.grain.defs',
2812
+
SocialGrainGalleryItem: 'social.grain.gallery.item',
2813
+
SocialGrainGalleryDefs: 'social.grain.gallery.defs',
2814
+
SocialGrainGallery: 'social.grain.gallery',
2815
+
SocialGrainFavorite: 'social.grain.favorite',
2816
+
SocialGrainActorDefs: 'social.grain.actor.defs',
2817
+
SocialGrainActorProfile: 'social.grain.actor.profile',
2818
+
SocialGrainPhotoDefs: 'social.grain.photo.defs',
2819
+
SocialGrainPhoto: 'social.grain.photo',
2766
2820
ComAtprotoLabelDefs: 'com.atproto.label.defs',
2767
2821
ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef',
2768
2822
} as const
+1
-1
deno.json
+1
-1
deno.json
···
2
2
"imports": {
3
3
"$lexicon/": "./__generated__/",
4
4
"@atproto/syntax": "npm:@atproto/syntax@^0.4.0",
5
-
"@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.5",
5
+
"@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.7",
6
6
"@gfx/canvas": "jsr:@gfx/canvas@^0.5.8",
7
7
"@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
8
8
"date-fns": "npm:date-fns@^4.1.0",
+6
-5
deno.lock
+6
-5
deno.lock
···
2
2
"version": "4",
3
3
"specifiers": {
4
4
"jsr:@bigmoves/atproto-oauth-client@0.1": "0.1.0",
5
-
"jsr:@bigmoves/bff@0.3.0-beta.5": "0.3.0-beta.5",
5
+
"jsr:@bigmoves/bff@0.3.0-beta.7": "0.3.0-beta.7",
6
6
"jsr:@denosaurs/plug@1": "1.0.5",
7
7
"jsr:@denosaurs/plug@1.0.5": "1.0.5",
8
8
"jsr:@gfx/canvas@~0.5.8": "0.5.8",
···
48
48
"npm:clsx@^2.1.1": "2.1.1",
49
49
"npm:date-fns@^4.1.0": "4.1.0",
50
50
"npm:jose@5.9.6": "5.9.6",
51
-
"npm:multiformats@*": "13.3.2",
51
+
"npm:multiformats@*": "9.9.0",
52
52
"npm:multiformats@^13.3.2": "13.3.2",
53
53
"npm:popmotion@^11.0.5": "11.0.5",
54
54
"npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.26.5",
···
70
70
"npm:jose"
71
71
]
72
72
},
73
-
"@bigmoves/bff@0.3.0-beta.5": {
74
-
"integrity": "e9de02ccf1b90c2b97e10511c7975d04c824a750822fb2486e350f3cc5e5717d",
73
+
"@bigmoves/bff@0.3.0-beta.7": {
74
+
"integrity": "cf9b6469e239abaad539aa5e06c786018731c1c0ab8b59debdbb8e83eebb6e83",
75
75
"dependencies": [
76
76
"jsr:@bigmoves/atproto-oauth-client",
77
77
"jsr:@std/assert@^1.0.12",
···
88
88
"npm:multiformats@^13.3.2",
89
89
"npm:preact",
90
90
"npm:preact-render-to-string",
91
+
"npm:sharp",
91
92
"npm:tailwind-merge"
92
93
]
93
94
},
···
1608
1609
},
1609
1610
"workspace": {
1610
1611
"dependencies": [
1611
-
"jsr:@bigmoves/bff@0.3.0-beta.5",
1612
+
"jsr:@bigmoves/bff@0.3.0-beta.7",
1612
1613
"jsr:@gfx/canvas@~0.5.8",
1613
1614
"npm:@atproto/syntax@0.4",
1614
1615
"npm:@tailwindcss/cli@^4.1.4",
+1
-1
fly.toml
+1
-1
fly.toml
+770
-399
main.tsx
+770
-399
main.tsx
···
1
1
import { lexicons } from "$lexicon/lexicons.ts";
2
2
import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts";
3
-
import { ProfileView } from "$lexicon/types/social/grain/v0/actor/defs.ts";
4
-
import { Record as Profile } from "$lexicon/types/social/grain/v0/actor/profile.ts";
5
-
import { Record as Gallery } from "$lexicon/types/social/grain/v0/gallery.ts";
3
+
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
4
+
import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts";
5
+
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
6
+
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
7
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
8
+
import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts";
9
+
import {
10
+
isRecord as isPhoto,
11
+
Record as Photo,
12
+
} from "$lexicon/types/social/grain/photo.ts";
6
13
import {
7
-
GalleryView,
8
-
Image as GalleryImage,
9
-
ViewImage,
10
-
} from "$lexicon/types/social/grain/v0/gallery/defs.ts";
11
-
import { Record as Star } from "$lexicon/types/social/grain/v0/gallery/star.ts";
12
-
import { Un$Typed } from "$lexicon/util.ts";
14
+
isPhotoView,
15
+
PhotoView,
16
+
} from "$lexicon/types/social/grain/photo/defs.ts";
17
+
import { $Typed, Un$Typed } from "$lexicon/util.ts";
13
18
import { AtUri } from "@atproto/syntax";
14
19
import {
15
20
bff,
···
45
50
46
51
bff({
47
52
appName: "Grain Social",
48
-
collections: ["social.grain.gallery", "social.grain.actor.profile"],
53
+
collections: [
54
+
"social.grain.gallery",
55
+
"social.grain.actor.profile",
56
+
"social.grain.photo",
57
+
"social.grain.favorite",
58
+
"social.grain.gallery.item",
59
+
],
49
60
jetstreamUrl: JETSTREAM.WEST_1,
50
61
lexicons,
51
62
rootElement: Root,
···
120
131
}),
121
132
route("/profile/:handle/:rkey", (_req, params, ctx: BffContext<State>) => {
122
133
const did = ctx.currentUser?.did;
123
-
let stars: WithBffMeta<Star>[] = [];
134
+
let favs: WithBffMeta<Favorite>[] = [];
124
135
const handle = params.handle;
125
136
const rkey = params.rkey;
126
137
const gallery = getGallery(handle, rkey, ctx);
127
138
if (did && gallery) {
128
-
stars = getGalleryStars(gallery.uri, ctx);
139
+
favs = getGalleryFavs(gallery.uri, ctx);
129
140
}
130
141
if (!gallery) return ctx.next();
131
142
ctx.state.meta = getGalleryMeta(gallery);
132
143
ctx.state.scripts = ["image_dialog.js"];
133
144
return ctx.render(
134
-
<GalleryPage stars={stars} gallery={gallery} currentUserDid={did} />,
145
+
<GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />,
135
146
);
136
147
}),
137
-
route("/profile/:handle/:rkey/edit", (_req, params, ctx) => {
148
+
149
+
route("/upload", (_req, _params, ctx) => {
138
150
requireAuth(ctx);
139
-
const handle = params.handle;
140
-
const rkey = params.rkey;
141
-
const gallery = getGallery(handle, rkey, ctx);
151
+
const photos = getActorPhotos(ctx.currentUser.did, ctx);
142
152
return ctx.render(
143
-
<GalleryCreateEditPage userHandle={handle} gallery={gallery} />,
153
+
<UploadPage photos={photos} />,
144
154
);
145
155
}),
146
-
route("/gallery/new", (_req, _params, ctx) => {
156
+
route("/dialogs/gallery/new", (_req, _params, ctx) => {
147
157
requireAuth(ctx);
148
-
return ctx.render(
149
-
<GalleryCreateEditPage userHandle={ctx.currentUser.handle} />,
158
+
return ctx.html(
159
+
<GalleryCreateEditDialog />,
160
+
);
161
+
}),
162
+
route("/dialogs/gallery/:rkey", (_req, params, ctx) => {
163
+
requireAuth(ctx);
164
+
const handle = ctx.currentUser.handle;
165
+
const rkey = params.rkey;
166
+
const gallery = getGallery(handle, rkey, ctx);
167
+
return ctx.html(
168
+
<GalleryCreateEditDialog gallery={gallery} />,
150
169
);
151
170
}),
152
171
route("/onboard", (_req, _params, ctx) => {
···
195
214
const galleryDid = atUri.hostname;
196
215
const galleryRkey = atUri.rkey;
197
216
const gallery = getGallery(galleryDid, galleryRkey, ctx);
198
-
if (!gallery?.images) return ctx.next();
199
-
const image = gallery?.images?.find((image) => {
200
-
return image.cid === imageCid;
217
+
if (!gallery?.items) return ctx.next();
218
+
const image = gallery.items.filter(isPhotoView).find((item) => {
219
+
return item.cid === imageCid;
201
220
});
202
-
const imageAtIndex = gallery.images.findIndex((image) => {
203
-
return image.cid === imageCid;
204
-
});
205
-
const next = wrap(0, gallery.images.length, imageAtIndex + 1);
206
-
const prev = wrap(0, gallery.images.length, imageAtIndex - 1);
221
+
const imageAtIndex = gallery.items.filter(isPhotoView).findIndex(
222
+
(image) => {
223
+
return image.cid === imageCid;
224
+
},
225
+
);
226
+
const next = wrap(0, gallery.items.length, imageAtIndex + 1);
227
+
const prev = wrap(0, gallery.items.length, imageAtIndex - 1);
207
228
if (!image) return ctx.next();
208
229
return ctx.html(
209
-
<ImageDialog
230
+
<PhotoDialog
210
231
gallery={gallery}
211
232
image={image}
212
-
nextImage={gallery.images.at(next)}
213
-
prevImage={gallery.images.at(prev)}
233
+
nextImage={gallery.items.filter(isPhotoView).at(next)}
234
+
prevImage={gallery.items.filter(isPhotoView).at(prev)}
214
235
/>,
215
236
);
216
237
}),
···
223
244
const galleryDid = atUri.hostname;
224
245
const galleryRkey = atUri.rkey;
225
246
const gallery = getGallery(galleryDid, galleryRkey, ctx);
226
-
const image = gallery?.images?.find((image) => {
227
-
return image.cid === imageCid;
247
+
const photo = gallery?.items?.filter(isPhotoView).find((photo) => {
248
+
return photo.cid === imageCid;
228
249
});
229
-
if (!image || !gallery) return ctx.next();
250
+
if (!photo || !gallery) return ctx.next();
251
+
return ctx.html(
252
+
<PhotoAltDialog galleryUri={gallery.uri} photo={photo} />,
253
+
);
254
+
}),
255
+
route("/dialogs/photo-select/:galleryRkey", (_req, params, ctx) => {
256
+
requireAuth(ctx);
257
+
const photos = getActorPhotos(ctx.currentUser.did, ctx);
258
+
const galleryUri =
259
+
`at://${ctx.currentUser.did}/social.grain.gallery/${params.galleryRkey}`;
260
+
const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(
261
+
galleryUri,
262
+
);
263
+
if (!gallery) return ctx.next();
264
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]);
265
+
const itemUris = galleryPhotosMap.get(galleryUri)?.map((photo) =>
266
+
photo.uri
267
+
) ?? [];
230
268
return ctx.html(
231
-
<ImageAltDialog galleryUri={gallery.uri} image={image} />,
269
+
<PhotoSelectDialog
270
+
galleryUri={galleryUri}
271
+
itemUris={itemUris}
272
+
photos={photos}
273
+
/>,
232
274
);
233
275
}),
234
276
route("/actions/create-edit", ["POST"], async (req, _params, ctx) => {
···
242
284
const uri = searchParams.get("uri");
243
285
const handle = ctx.currentUser?.handle;
244
286
245
-
let images: GalleryImage[] = [];
246
-
for (const cid of cids) {
247
-
const blobMeta = ctx.blobMetaCache.get(cid);
248
-
if (!blobMeta?.blobRef) {
249
-
continue;
250
-
}
251
-
images.push({
252
-
image: blobMeta.blobRef,
253
-
alt: "",
254
-
aspectRatio: blobMeta.dimensions?.width && blobMeta.dimensions?.height
255
-
? {
256
-
width: blobMeta.dimensions.width,
257
-
height: blobMeta.dimensions.height,
258
-
}
259
-
: undefined,
260
-
});
261
-
}
262
-
263
287
if (uri) {
264
288
const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(uri);
265
289
if (!gallery) return ctx.next();
266
-
images = mergeUniqueImages(gallery.images, images, cids);
267
290
const rkey = new AtUri(uri).rkey;
268
291
try {
269
292
await ctx.updateRecord<Gallery>("social.grain.gallery", rkey, {
270
293
title,
271
294
description,
272
-
images,
273
295
createdAt: gallery.createdAt,
274
296
});
275
297
} catch (e) {
···
287
309
{
288
310
title,
289
311
description,
290
-
images,
291
312
createdAt: new Date().toISOString(),
292
313
},
293
314
);
294
315
return ctx.redirect(galleryLink(handle, new AtUri(createdUri).rkey));
295
316
}),
296
-
route("/actions/delete", ["POST"], async (req, _params, ctx) => {
317
+
route("/actions/gallery/delete", ["POST"], async (req, _params, ctx) => {
297
318
requireAuth(ctx);
298
319
const formData = await req.formData();
299
320
const uri = formData.get("uri") as string;
300
321
await ctx.deleteRecord(uri);
301
322
return ctx.redirect("/");
302
323
}),
303
-
route("/actions/image-alt", ["POST"], async (req, _params, ctx) => {
324
+
route(
325
+
"/actions/gallery/:galleryRkey/add-photo/:photoRkey",
326
+
["PUT"],
327
+
async (_req, params, ctx) => {
328
+
requireAuth(ctx);
329
+
const galleryRkey = params.galleryRkey;
330
+
const photoRkey = params.photoRkey;
331
+
const galleryUri =
332
+
`at://${ctx.currentUser.did}/social.grain.gallery/${galleryRkey}`;
333
+
const photoUri =
334
+
`at://${ctx.currentUser.did}/social.grain.photo/${photoRkey}`;
335
+
const gallery = getGallery(ctx.currentUser.did, galleryRkey, ctx);
336
+
const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri);
337
+
if (!gallery || !photo) return ctx.next();
338
+
if (
339
+
gallery.items?.filter(isPhotoView).some((item) =>
340
+
item.uri === photoUri
341
+
)
342
+
) {
343
+
return new Response(null, { status: 500 });
344
+
}
345
+
await ctx.createRecord<Gallery>(
346
+
"social.grain.gallery.item",
347
+
{
348
+
gallery: galleryUri,
349
+
item: photoUri,
350
+
createdAt: new Date().toISOString(),
351
+
},
352
+
);
353
+
gallery.items = [
354
+
...(gallery.items ?? []),
355
+
photoToView(photo.did, photo),
356
+
];
357
+
return ctx.html(
358
+
<>
359
+
<div hx-swap-oob="beforeend:#gallery-photo-grid">
360
+
<PhotoButton
361
+
key={photo.cid}
362
+
photo={photoToView(photo.did, photo)}
363
+
gallery={gallery}
364
+
isCreator={ctx.currentUser.did === gallery.creator.did}
365
+
isLoggedIn={!!ctx.currentUser.did}
366
+
/>
367
+
</div>
368
+
<PhotoSelectButton
369
+
galleryUri={galleryUri}
370
+
itemUris={gallery.items?.filter(isPhotoView).map((item) =>
371
+
item.uri
372
+
) ?? []}
373
+
photo={photoToView(photo.did, photo)}
374
+
/>
375
+
</>,
376
+
);
377
+
},
378
+
),
379
+
route(
380
+
"/actions/gallery/:galleryRkey/remove-photo/:photoRkey",
381
+
["PUT"],
382
+
async (_req, params, ctx) => {
383
+
requireAuth(ctx);
384
+
const galleryRkey = params.galleryRkey;
385
+
const photoRkey = params.photoRkey;
386
+
const galleryUri =
387
+
`at://${ctx.currentUser.did}/social.grain.gallery/${galleryRkey}`;
388
+
const photoUri =
389
+
`at://${ctx.currentUser.did}/social.grain.photo/${photoRkey}`;
390
+
if (!galleryRkey || !photoRkey) return ctx.next();
391
+
const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri);
392
+
if (!photo) return ctx.next();
393
+
const { items: [item] } = ctx.indexService.getRecords<
394
+
WithBffMeta<GalleryItem>
395
+
>(
396
+
"social.grain.gallery.item",
397
+
{
398
+
where: [
399
+
{
400
+
field: "gallery",
401
+
equals: galleryUri,
402
+
},
403
+
{
404
+
field: "item",
405
+
equals: photoUri,
406
+
},
407
+
],
408
+
},
409
+
);
410
+
if (!item) return ctx.next();
411
+
await ctx.deleteRecord(
412
+
item.uri,
413
+
);
414
+
const gallery = getGallery(ctx.currentUser.did, galleryRkey, ctx);
415
+
if (!gallery) return ctx.next();
416
+
return ctx.html(
417
+
<PhotoSelectButton
418
+
galleryUri={galleryUri}
419
+
itemUris={gallery.items?.filter(isPhotoView).map((item) =>
420
+
item.uri
421
+
) ?? []}
422
+
photo={photoToView(photo.did, photo)}
423
+
/>,
424
+
);
425
+
},
426
+
),
427
+
route("/actions/photo/:rkey", ["PUT"], async (req, params, ctx) => {
304
428
requireAuth(ctx);
429
+
const photoRkey = params.rkey;
305
430
const formData = await req.formData();
306
431
const alt = formData.get("alt") as string;
307
-
const cid = formData.get("cid") as string;
308
-
const galleryUri = formData.get("galleryUri") as string;
309
-
const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(
310
-
galleryUri,
311
-
);
312
-
if (!gallery) return ctx.next();
313
-
const images = gallery?.images?.map((image) => {
314
-
if (image.image.ref.toString() === cid) {
315
-
return {
316
-
...image,
317
-
alt,
318
-
};
319
-
}
320
-
return image;
321
-
});
322
-
const rkey = new AtUri(galleryUri).rkey;
323
-
await ctx.updateRecord<Gallery>("social.grain.gallery", rkey, {
324
-
title: gallery.title,
325
-
description: gallery.description,
326
-
images,
327
-
createdAt: gallery.createdAt,
432
+
const photoUri =
433
+
`at://${ctx.currentUser.did}/social.grain.photo/${photoRkey}`;
434
+
const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri);
435
+
if (!photo) return ctx.next();
436
+
await ctx.updateRecord<Photo>("social.grain.photo", photoRkey, {
437
+
photo: photo.photo,
438
+
aspectRatio: photo.aspectRatio,
439
+
alt,
440
+
createdAt: photo.createdAt,
328
441
});
329
442
return new Response(null, { status: 200 });
330
443
}),
331
-
route("/actions/star", ["POST"], async (req, _params, ctx) => {
444
+
route("/actions/favorite", ["POST"], async (req, _params, ctx) => {
332
445
requireAuth(ctx);
333
446
const url = new URL(req.url);
334
447
const searchParams = new URLSearchParams(url.search);
335
448
const galleryUri = searchParams.get("galleryUri");
336
-
const starUri = searchParams.get("starUri") ?? undefined;
449
+
const favUri = searchParams.get("favUri") ?? undefined;
337
450
if (!galleryUri) return ctx.next();
338
451
339
-
if (starUri) {
340
-
await ctx.deleteRecord(starUri);
341
-
const stars = getGalleryStars(galleryUri, ctx);
452
+
if (favUri) {
453
+
await ctx.deleteRecord(favUri);
454
+
const favs = getGalleryFavs(galleryUri, ctx);
342
455
return ctx.html(
343
-
<StarButton
456
+
<FavoriteButton
344
457
currentUserDid={ctx.currentUser.did}
345
-
stars={stars}
458
+
favs={favs}
346
459
galleryUri={galleryUri}
347
460
/>,
348
461
);
349
462
}
350
463
351
-
await ctx.createRecord<WithBffMeta<Star>>(
352
-
"social.grain.gallery.star",
464
+
await ctx.createRecord<WithBffMeta<Favorite>>(
465
+
"social.grain.favorite",
353
466
{
354
467
subject: galleryUri,
355
468
createdAt: new Date().toISOString(),
356
469
},
357
470
);
358
471
359
-
const stars = getGalleryStars(galleryUri, ctx);
472
+
const favs = getGalleryFavs(galleryUri, ctx);
360
473
361
474
return ctx.html(
362
-
<StarButton
475
+
<FavoriteButton
363
476
currentUserDid={ctx.currentUser.did}
364
477
galleryUri={galleryUri}
365
-
stars={stars}
478
+
favs={favs}
366
479
/>,
367
480
);
368
481
}),
···
389
502
390
503
return ctx.redirect(`/profile/${ctx.currentUser.handle}`);
391
504
}),
392
-
...imageUploadRoutes(),
505
+
route("/actions/photo/:rkey", ["DELETE"], (_req, params, ctx) => {
506
+
requireAuth(ctx);
507
+
ctx.deleteRecord(
508
+
`at://${ctx.currentUser.did}/social.grain.photo/${params.rkey}`,
509
+
);
510
+
return new Response(null, { status: 200 });
511
+
}),
512
+
...photoUploadRoutes(),
393
513
...avatarUploadRoutes(),
394
514
],
395
515
});
···
458
578
return canvas.toDataURL(format);
459
579
}
460
580
461
-
type TimelineItemType = "gallery" | "star";
581
+
type TimelineItemType = "gallery" | "favorite";
462
582
463
-
interface TimelineItem {
583
+
type TimelineItem = {
464
584
createdAt: string;
465
585
itemType: TimelineItemType;
466
586
itemUri: string;
467
587
actor: Un$Typed<ProfileView>;
468
588
gallery: GalleryView;
469
-
}
589
+
};
470
590
471
-
interface TimelineOptions {
591
+
type TimelineOptions = {
472
592
actorDid?: string;
593
+
};
594
+
595
+
function getGalleryItemsAndPhotos(
596
+
ctx: BffContext,
597
+
galleries: WithBffMeta<Gallery>[],
598
+
): Map<string, WithBffMeta<Photo>[]> {
599
+
const galleryUris = galleries.map((gallery) =>
600
+
`at://${gallery.did}/social.grain.gallery/${new AtUri(gallery.uri).rkey}`
601
+
);
602
+
603
+
if (galleryUris.length === 0) return new Map();
604
+
605
+
const { items: galleryItems } = ctx.indexService.getRecords<
606
+
WithBffMeta<GalleryItem>
607
+
>("social.grain.gallery.item", {
608
+
where: [{ field: "gallery", in: galleryUris }],
609
+
});
610
+
611
+
const photoUris = galleryItems.map((item) => item.item).filter(Boolean);
612
+
if (photoUris.length === 0) return new Map();
613
+
614
+
const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>(
615
+
"social.grain.photo",
616
+
{
617
+
where: [{ field: "uri", in: photoUris }],
618
+
},
619
+
);
620
+
621
+
const photosMap = new Map<string, WithBffMeta<Photo>>();
622
+
for (const photo of photos) {
623
+
photosMap.set(photo.uri, photo);
624
+
}
625
+
626
+
const galleryPhotosMap = new Map<string, WithBffMeta<Photo>[]>();
627
+
for (const item of galleryItems) {
628
+
const galleryUri = item.gallery;
629
+
const photo = photosMap.get(item.item);
630
+
631
+
if (!galleryPhotosMap.has(galleryUri)) {
632
+
galleryPhotosMap.set(galleryUri, []);
633
+
}
634
+
635
+
if (photo) {
636
+
galleryPhotosMap.get(galleryUri)?.push(photo);
637
+
}
638
+
}
639
+
640
+
return galleryPhotosMap;
473
641
}
474
642
475
643
function processGalleries(
···
489
657
where: whereClause,
490
658
});
491
659
660
+
if (galleries.length === 0) return items;
661
+
662
+
// Get photos for all galleries
663
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
664
+
492
665
for (const gallery of galleries) {
493
666
const actor = ctx.indexService.getActor(gallery.did);
494
667
if (!actor) continue;
495
668
const profile = getActorProfile(actor.did, ctx);
496
669
if (!profile) continue;
497
670
498
-
const galleryView = galleryToView(gallery, profile);
671
+
const galleryUri = `at://${gallery.did}/social.grain.gallery/${
672
+
new AtUri(gallery.uri).rkey
673
+
}`;
674
+
const galleryPhotos = galleryPhotosMap.get(galleryUri) || [];
675
+
676
+
const galleryView = galleryToView(gallery, profile, galleryPhotos);
499
677
items.push({
500
678
itemType: "gallery",
501
679
createdAt: gallery.createdAt,
···
518
696
? [{ field: "did", equals: options.actorDid }]
519
697
: undefined;
520
698
521
-
const { items: stars } = ctx.indexService.getRecords<WithBffMeta<Star>>(
522
-
"social.grain.gallery.star",
699
+
const { items: favs } = ctx.indexService.getRecords<WithBffMeta<Favorite>>(
700
+
"social.grain.favorite",
523
701
{
524
702
orderBy: { field: "createdAt", direction: "desc" },
525
703
where: whereClause,
526
704
},
527
705
);
528
706
529
-
for (const star of stars) {
530
-
if (!star.subject) continue;
707
+
if (favs.length === 0) return items;
708
+
709
+
// Collect all gallery references from favorites
710
+
const galleryRefs = new Map<string, WithBffMeta<Gallery>>();
711
+
712
+
for (const favorite of favs) {
713
+
if (!favorite.subject) continue;
531
714
532
715
try {
533
-
const atUri = new AtUri(star.subject);
716
+
const atUri = new AtUri(favorite.subject);
534
717
const galleryDid = atUri.hostname;
535
718
const galleryRkey = atUri.rkey;
719
+
const galleryUri =
720
+
`at://${galleryDid}/social.grain.gallery/${galleryRkey}`;
536
721
537
722
const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(
538
-
`at://${galleryDid}/social.grain.gallery/${galleryRkey}`,
723
+
galleryUri,
539
724
);
725
+
if (gallery) {
726
+
galleryRefs.set(galleryUri, gallery);
727
+
}
728
+
} catch (e) {
729
+
console.error("Error processing favorite:", e);
730
+
}
731
+
}
732
+
733
+
const galleries = Array.from(galleryRefs.values());
734
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
735
+
736
+
for (const favorite of favs) {
737
+
if (!favorite.subject) continue;
738
+
739
+
try {
740
+
const atUri = new AtUri(favorite.subject);
741
+
const galleryDid = atUri.hostname;
742
+
const galleryRkey = atUri.rkey;
743
+
const galleryUri =
744
+
`at://${galleryDid}/social.grain.gallery/${galleryRkey}`;
745
+
746
+
const gallery = galleryRefs.get(galleryUri);
540
747
if (!gallery) continue;
541
748
542
749
const galleryActor = ctx.indexService.getActor(galleryDid);
···
544
751
const galleryProfile = getActorProfile(galleryActor.did, ctx);
545
752
if (!galleryProfile) continue;
546
753
547
-
const starActor = ctx.indexService.getActor(star.did);
548
-
if (!starActor) continue;
549
-
const starProfile = getActorProfile(starActor.did, ctx);
550
-
if (!starProfile) continue;
754
+
const favActor = ctx.indexService.getActor(favorite.did);
755
+
if (!favActor) continue;
756
+
const favProfile = getActorProfile(favActor.did, ctx);
757
+
if (!favProfile) continue;
758
+
759
+
const galleryPhotos = galleryPhotosMap.get(galleryUri) || [];
760
+
const galleryView = galleryToView(gallery, galleryProfile, galleryPhotos);
551
761
552
-
const galleryView = galleryToView(gallery, galleryProfile);
553
762
items.push({
554
-
itemType: "star",
555
-
createdAt: star.createdAt,
556
-
itemUri: star.uri,
557
-
actor: starProfile,
763
+
itemType: "favorite",
764
+
createdAt: favorite.createdAt,
765
+
itemUri: favorite.uri,
766
+
actor: favProfile,
558
767
gallery: galleryView,
559
768
});
560
769
} catch (e) {
561
-
console.error("Error processing star:", e);
770
+
console.error("Error processing favorite:", e);
562
771
continue;
563
772
}
564
773
}
···
571
780
options?: TimelineOptions,
572
781
): TimelineItem[] {
573
782
const galleryItems = processGalleries(ctx, options);
574
-
const starItems = processStars(ctx, options);
575
-
const timelineItems = [...galleryItems, ...starItems];
783
+
const favsItems = processStars(ctx, options);
784
+
const timelineItems = [...galleryItems, ...favsItems];
576
785
577
786
return timelineItems.sort(
578
787
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
···
595
804
return getTimelineItems(ctx, { actorDid: did });
596
805
}
597
806
807
+
function getActorPhotos(handleOrDid: string, ctx: BffContext) {
808
+
let did: string;
809
+
if (handleOrDid.includes("did:")) {
810
+
did = handleOrDid;
811
+
} else {
812
+
const actor = ctx.indexService.getActorByHandle(handleOrDid);
813
+
if (!actor) return [];
814
+
did = actor.did;
815
+
}
816
+
const photos = ctx.indexService.getRecords<WithBffMeta<Photo>>(
817
+
"social.grain.photo",
818
+
{
819
+
where: [{ field: "did", equals: did }],
820
+
orderBy: { field: "createdAt", direction: "desc" },
821
+
},
822
+
);
823
+
return photos.items.map((photo) => photoToView(photo.did, photo));
824
+
}
825
+
598
826
function getActorGalleries(handleOrDid: string, ctx: BffContext) {
599
827
let did: string;
600
828
if (handleOrDid.includes("did:")) {
···
604
832
if (!actor) return [];
605
833
did = actor.did;
606
834
}
607
-
const galleries = ctx.indexService.getRecords<WithBffMeta<Gallery>>(
835
+
const { items: galleries } = ctx.indexService.getRecords<
836
+
WithBffMeta<Gallery>
837
+
>(
608
838
"social.grain.gallery",
609
839
{
610
840
where: [{ field: "did", equals: did }],
611
841
orderBy: { field: "createdAt", direction: "desc" },
612
842
},
613
843
);
844
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
614
845
const creator = getActorProfile(did, ctx);
615
846
if (!creator) return [];
616
-
return galleries.items.map((gallery) => galleryToView(gallery, creator));
847
+
return galleries.map((gallery) =>
848
+
galleryToView(gallery, creator, galleryPhotosMap.get(gallery.uri) ?? [])
849
+
);
617
850
}
618
851
619
852
function getGallery(handleOrDid: string, rkey: string, ctx: BffContext) {
···
629
862
`at://${did}/social.grain.gallery/${rkey}`,
630
863
);
631
864
if (!gallery) return null;
865
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]);
632
866
const profile = getActorProfile(did, ctx);
633
867
if (!profile) return null;
634
-
return galleryToView(gallery, profile);
868
+
return galleryToView(
869
+
gallery,
870
+
profile,
871
+
galleryPhotosMap.get(gallery.uri) ?? [],
872
+
);
635
873
}
636
874
637
-
function getGalleryStars(galleryUri: string, ctx: BffContext) {
875
+
function getGalleryFavs(galleryUri: string, ctx: BffContext) {
638
876
const atUri = new AtUri(galleryUri);
639
-
const results = ctx.indexService.getRecords<WithBffMeta<Star>>(
640
-
"social.grain.gallery.star",
877
+
const results = ctx.indexService.getRecords<WithBffMeta<Favorite>>(
878
+
"social.grain.favorite",
641
879
{
642
880
where: [
643
881
{
644
-
field: "subject",
882
+
field: "gallery",
645
883
equals: `at://${atUri.hostname}/social.grain.gallery/${atUri.rkey}`,
646
884
},
647
885
],
···
665
903
property: "og:description",
666
904
content: (gallery.record as Gallery).description,
667
905
},
668
-
{ property: "og:image", content: gallery?.images?.[0].thumb },
906
+
{
907
+
property: "og:image",
908
+
content: gallery?.items?.filter(isPhotoView)?.[0]?.thumb,
909
+
},
669
910
];
670
911
}
671
912
···
797
1038
>
798
1039
@{item.actor.handle}
799
1040
</a>{" "}
800
-
{item.itemType === "star" ? "starred" : "created"}{" "}
1041
+
{item.itemType === "favorite" ? "favorited" : "created"}{" "}
801
1042
<a
802
1043
href={galleryLink(
803
1044
item.gallery.creator.handle,
···
820
1061
)}
821
1062
class="w-fit flex"
822
1063
>
823
-
{item.gallery.images?.length
1064
+
{item.gallery.items?.filter(isPhotoView).length
824
1065
? (
825
1066
<div class="flex w-full max-w-md mx-auto aspect-[3/2] overflow-hidden gap-2">
826
1067
<div class="w-2/3 h-full">
827
1068
<img
828
-
src={item.gallery.images[0].thumb}
829
-
alt={item.gallery.images[0].alt}
1069
+
src={item.gallery.items?.filter(isPhotoView)[0].thumb}
1070
+
alt={item.gallery.items?.filter(isPhotoView)[0].alt}
830
1071
class="w-full h-full object-cover"
831
1072
/>
832
1073
</div>
833
1074
<div class="w-1/3 flex flex-col h-full gap-2">
834
1075
<div class="h-1/2">
835
-
{item.gallery.images?.[1]
1076
+
{item.gallery.items?.filter(isPhotoView)?.[1]
836
1077
? (
837
1078
<img
838
-
src={item.gallery.images?.[1]?.thumb}
839
-
alt={item.gallery.images?.[1]?.alt}
1079
+
src={item.gallery.items?.filter(isPhotoView)?.[1]
1080
+
?.thumb}
1081
+
alt={item.gallery.items?.filter(isPhotoView)?.[1]?.alt}
840
1082
class="w-full h-full object-cover"
841
1083
/>
842
1084
)
843
1085
: <div className="w-full h-full bg-gray-200" />}
844
1086
</div>
845
1087
<div class="h-1/2">
846
-
{item.gallery.images?.[2]
1088
+
{item.gallery.items?.filter(isPhotoView)?.[2]
847
1089
? (
848
1090
<img
849
-
src={item.gallery.images?.[2]?.thumb}
850
-
alt={item.gallery.images?.[2]?.alt}
1091
+
src={item.gallery.items?.filter(isPhotoView)?.[2]
1092
+
?.thumb}
1093
+
alt={item.gallery.items?.filter(isPhotoView)?.[2]?.alt}
851
1094
class="w-full h-full object-cover"
852
1095
/>
853
1096
)
···
887
1130
{loggedInUserDid === profile.did
888
1131
? (
889
1132
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row">
1133
+
<Button variant="secondary" class="w-full sm:w-fit" asChild>
1134
+
<a href="/upload">
1135
+
<i class="fa-solid fa-upload mr-2" />
1136
+
Upload
1137
+
</a>
1138
+
</Button>
890
1139
<Button
891
1140
variant="primary"
892
1141
type="button"
···
897
1146
>
898
1147
Edit Profile
899
1148
</Button>
900
-
<Button variant="primary" class="w-full sm:w-fit" asChild>
901
-
<a href="/gallery/new">Create Gallery</a>
1149
+
<Button
1150
+
variant="primary"
1151
+
type="button"
1152
+
class="w-full sm:w-fit"
1153
+
hx-get="/dialogs/gallery/new"
1154
+
hx-target="#layout"
1155
+
hx-swap="afterbegin"
1156
+
>
1157
+
Create Gallery
902
1158
</Button>
903
1159
</div>
904
1160
)
···
940
1196
{!selectedTab
941
1197
? (
942
1198
<ul class="space-y-4 relative">
943
-
{timelineItems.map((item) => (
944
-
<TimelineItem item={item} key={item.itemUri} />
945
-
))}
1199
+
{timelineItems.length
1200
+
? timelineItems.map((item) => (
1201
+
<TimelineItem item={item} key={item.itemUri} />
1202
+
))
1203
+
: <li>No activity yet.</li>}
946
1204
</ul>
947
1205
)
948
1206
: null}
···
960
1218
class="cursor-pointer relative aspect-square"
961
1219
>
962
1220
<img
963
-
src={gallery.images?.[0]?.thumb}
964
-
alt={gallery.images?.[0]?.alt}
1221
+
src={gallery.items?.filter(isPhotoView)?.[0]?.thumb}
1222
+
alt={gallery.items?.filter(isPhotoView)?.[0]?.alt}
965
1223
class="w-full h-full object-cover"
966
1224
/>
967
1225
<div class="absolute bottom-0 left-0 bg-black/80 text-white p-2">
···
970
1228
</a>
971
1229
))
972
1230
)
973
-
: <p>No galleries found</p>}
1231
+
: <p>No galleries yet.</p>}
974
1232
</div>
975
1233
)
976
1234
: null}
···
979
1237
);
980
1238
}
981
1239
1240
+
function UploadPage({ photos }: Readonly<{ photos: PhotoView[] }>) {
1241
+
return (
1242
+
<div class="px-4 pt-4 mb-4">
1243
+
<Button variant="primary" class="mb-2" asChild>
1244
+
<label class="w-fit">
1245
+
<i class="fa fa-plus"></i> Add photos
1246
+
<input
1247
+
class="hidden"
1248
+
type="file"
1249
+
multiple
1250
+
accept="image/*"
1251
+
_="on change
1252
+
set fileList to me.files
1253
+
if fileList.length > 10
1254
+
alert('You can only upload 10 photos')
1255
+
halt
1256
+
end
1257
+
for file in fileList
1258
+
make a FormData called fd
1259
+
fd.append('file', file)
1260
+
fetch /actions/photo/upload-start with { method:'POST', body:fd }
1261
+
then put it at the start of #image-preview
1262
+
then call htmx.process(#image-preview)
1263
+
end
1264
+
set me.value to ''"
1265
+
/>
1266
+
</label>
1267
+
</Button>
1268
+
<div
1269
+
id="image-preview"
1270
+
class="w-full h-full grid grid-cols-2 sm:grid-cols-5 gap-2"
1271
+
>
1272
+
{photos.map((photo) => (
1273
+
<PhotoPreview key={photo.cid} src={photo.thumb} uri={photo.uri} />
1274
+
))}
1275
+
</div>
1276
+
</div>
1277
+
);
1278
+
}
1279
+
982
1280
function ProfileDialog({
983
1281
profile,
984
1282
avatarCid,
···
1082
1380
1083
1381
function GalleryPage({
1084
1382
gallery,
1085
-
stars = [],
1383
+
favs = [],
1086
1384
currentUserDid,
1087
1385
}: Readonly<{
1088
1386
gallery: GalleryView;
1089
-
stars: WithBffMeta<Star>[];
1387
+
favs: WithBffMeta<Favorite>[];
1090
1388
currentUserDid?: string;
1091
1389
}>) {
1092
1390
const isCreator = currentUserDid === gallery.creator.did;
···
1113
1411
</div>
1114
1412
{isLoggedIn && isCreator
1115
1413
? (
1116
-
<Button
1117
-
variant="primary"
1118
-
class="self-start w-full sm:w-fit"
1119
-
asChild
1120
-
>
1121
-
<a
1122
-
href={`${
1123
-
galleryLink(
1124
-
gallery.creator.handle,
1125
-
new AtUri(gallery.uri).rkey,
1126
-
)
1127
-
}/edit`}
1414
+
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row">
1415
+
<Button
1416
+
hx-get={`/dialogs/photo-select/${new AtUri(gallery.uri).rkey}`}
1417
+
hx-target="#layout"
1418
+
hx-swap="afterbegin"
1419
+
variant="primary"
1420
+
class="self-start w-full sm:w-fit"
1421
+
>
1422
+
Add photos
1423
+
</Button>
1424
+
<Button
1425
+
variant="primary"
1426
+
class="self-start w-full sm:w-fit"
1427
+
hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`}
1428
+
hx-target="#layout"
1429
+
hx-swap="afterbegin"
1128
1430
>
1129
1431
Edit
1130
-
</a>
1131
-
</Button>
1432
+
</Button>
1433
+
</div>
1132
1434
)
1133
1435
: null}
1134
1436
{!isCreator
1135
1437
? (
1136
-
<StarButton
1438
+
<FavoriteButton
1137
1439
currentUserDid={currentUserDid}
1138
-
stars={stars}
1440
+
favs={favs}
1139
1441
galleryUri={gallery.uri}
1140
1442
/>
1141
1443
)
1142
1444
: null}
1143
1445
</div>
1144
-
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
1145
-
{gallery.images?.length
1146
-
? gallery?.images?.map((image) => (
1147
-
<button
1148
-
key={image.fullsize}
1149
-
type="button"
1150
-
hx-get={imageDialogLink(gallery, image)}
1151
-
hx-trigger="click"
1152
-
hx-target="#layout"
1153
-
hx-swap="afterbegin"
1154
-
class="cursor-pointer relative sm:aspect-square"
1155
-
>
1156
-
{isLoggedIn && isCreator
1157
-
? <AltTextButton galleryUri={gallery.uri} cid={image.cid} />
1158
-
: null}
1159
-
<img
1160
-
src={image.fullsize}
1161
-
alt={image.alt}
1162
-
class="sm:absolute sm:inset-0 w-full h-full sm:object-contain"
1163
-
/>
1164
-
{!isCreator && image.alt
1165
-
? (
1166
-
<div class="absolute bg-black/80 bottom-2 right-2 sm:bottom-0 sm:right-0 text-xs text-white font-semibold py-[1px] px-[3px]">
1167
-
ALT
1168
-
</div>
1169
-
)
1170
-
: null}
1171
-
</button>
1446
+
<div
1447
+
id="gallery-photo-grid"
1448
+
class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"
1449
+
>
1450
+
{gallery.items?.filter(isPhotoView)?.length
1451
+
? gallery?.items?.filter(isPhotoView)?.map((photo) => (
1452
+
<PhotoButton
1453
+
key={photo.cid}
1454
+
photo={photo}
1455
+
gallery={gallery}
1456
+
isCreator={isCreator}
1457
+
isLoggedIn={isLoggedIn}
1458
+
/>
1172
1459
))
1173
1460
: null}
1174
1461
</div>
···
1176
1463
);
1177
1464
}
1178
1465
1179
-
function StarButton({
1466
+
function PhotoButton({ photo, gallery, isCreator, isLoggedIn }: Readonly<{
1467
+
photo: PhotoView;
1468
+
gallery: GalleryView;
1469
+
isCreator: boolean;
1470
+
isLoggedIn: boolean;
1471
+
}>) {
1472
+
return (
1473
+
<button
1474
+
id={`photo-${new AtUri(photo.uri).rkey}`}
1475
+
type="button"
1476
+
hx-get={photoDialogLink(gallery, photo)}
1477
+
hx-trigger="click"
1478
+
hx-target="#layout"
1479
+
hx-swap="afterbegin"
1480
+
class="cursor-pointer relative sm:aspect-square"
1481
+
>
1482
+
{isLoggedIn && isCreator
1483
+
? <AltTextButton galleryUri={gallery.uri} cid={photo.cid} />
1484
+
: null}
1485
+
<img
1486
+
src={photo.fullsize}
1487
+
alt={photo.alt}
1488
+
class="sm:absolute sm:inset-0 w-full h-full sm:object-contain"
1489
+
/>
1490
+
{!isCreator && photo.alt
1491
+
? (
1492
+
<div class="absolute bg-black/80 bottom-2 right-2 sm:bottom-0 sm:right-0 text-xs text-white font-semibold py-[1px] px-[3px]">
1493
+
ALT
1494
+
</div>
1495
+
)
1496
+
: null}
1497
+
</button>
1498
+
);
1499
+
}
1500
+
1501
+
function FavoriteButton({
1180
1502
currentUserDid,
1181
-
stars = [],
1503
+
favs = [],
1182
1504
galleryUri,
1183
1505
}: Readonly<{
1184
1506
currentUserDid?: string;
1185
-
stars: WithBffMeta<Star>[];
1507
+
favs: WithBffMeta<Favorite>[];
1186
1508
galleryUri: string;
1187
1509
}>) {
1188
-
const starUri = stars.find((s) => currentUserDid === s.did)?.uri;
1510
+
const favUri = favs.find((s) => currentUserDid === s.did)?.uri;
1189
1511
return (
1190
1512
<Button
1191
1513
variant="primary"
1192
1514
class="self-start w-full sm:w-fit"
1193
1515
type="button"
1194
-
hx-post={`/actions/star?galleryUri=${galleryUri}${
1195
-
starUri ? "&starUri=" + starUri : ""
1516
+
hx-post={`/actions/favorite?galleryUri=${galleryUri}${
1517
+
favUri ? "&favUri=" + favUri : ""
1196
1518
}`}
1197
-
hx-trigger="click"
1198
1519
hx-target="this"
1199
1520
hx-swap="outerHTML"
1200
1521
>
1201
-
<i class={cn("fa-star", starUri ? "fa-solid" : "fa-regular")}></i>{" "}
1202
-
{stars.length}
1522
+
<i class={cn("fa-heart", favUri ? "fa-solid" : "fa-regular")}></i>{" "}
1523
+
{favs.length}
1203
1524
</Button>
1204
1525
);
1205
1526
}
1206
1527
1207
-
function BackBtn({ href }: Readonly<{ href: string }>) {
1208
-
return (
1209
-
<a href={href} class="w-fit flex items-center gap-1 mb-2">
1210
-
<i class="fas fa-arrow-left"></i> Back
1211
-
</a>
1212
-
);
1213
-
}
1214
-
1215
-
function GalleryCreateEditPage({
1216
-
userHandle,
1528
+
function GalleryCreateEditDialog({
1217
1529
gallery,
1218
-
}: Readonly<{ userHandle: string; gallery?: GalleryView | null }>) {
1530
+
}: Readonly<{ gallery?: GalleryView | null }>) {
1219
1531
return (
1220
-
<div class="p-4">
1221
-
<BackBtn
1222
-
href={gallery
1223
-
? galleryLink(gallery.creator.handle, new AtUri(gallery.uri).rkey)
1224
-
: profileLink(userHandle)}
1225
-
/>
1226
-
<Header class="mb-2">
1227
-
{gallery ? "Edit gallery" : "Create a new gallery"}
1228
-
</Header>
1229
-
<form
1230
-
id="gallery-form"
1231
-
class="max-w-xl"
1232
-
hx-post={`/actions/create-edit${gallery ? "?uri=" + gallery?.uri : ""}`}
1233
-
hx-swap="none"
1234
-
_="on htmx:afterOnLoad
1532
+
<Dialog id="gallery-dialog" class="z-30">
1533
+
<Dialog.Content>
1534
+
<Dialog.Title>
1535
+
{gallery ? "Edit gallery" : "Create a new gallery"}
1536
+
</Dialog.Title>
1537
+
<form
1538
+
id="gallery-form"
1539
+
class="max-w-xl"
1540
+
hx-post={`/actions/create-edit${
1541
+
gallery ? "?uri=" + gallery?.uri : ""
1542
+
}`}
1543
+
hx-swap="none"
1544
+
_="on htmx:afterOnLoad
1235
1545
if event.detail.xhr.status != 200
1236
1546
alert('Error: ' + event.detail.xhr.responseText)"
1237
-
>
1238
-
<div id="image-cids">
1239
-
{(gallery?.record as Gallery).images?.map((image) => (
1240
-
<input
1241
-
type="hidden"
1242
-
name="cids"
1243
-
value={image.image.ref.toString()}
1547
+
>
1548
+
<div class="mb-4 relative">
1549
+
<label htmlFor="title">Gallery name</label>
1550
+
<Input
1551
+
type="text"
1552
+
id="title"
1553
+
name="title"
1554
+
class="input"
1555
+
required
1556
+
value={(gallery?.record as Gallery)?.title}
1557
+
autofocus
1244
1558
/>
1245
-
))}
1246
-
</div>
1247
-
<div class="mb-4 relative">
1248
-
<label htmlFor="title">Gallery name</label>
1249
-
<Input
1250
-
type="text"
1251
-
id="title"
1252
-
name="title"
1253
-
class="input"
1254
-
required
1255
-
value={(gallery?.record as Gallery)?.title}
1559
+
</div>
1560
+
<div class="mb-2 relative">
1561
+
<label htmlFor="description">Description</label>
1562
+
<Textarea
1563
+
id="description"
1564
+
name="description"
1565
+
rows={4}
1566
+
class="input"
1567
+
>
1568
+
{(gallery?.record as Gallery)?.description}
1569
+
</Textarea>
1570
+
</div>
1571
+
</form>
1572
+
<div class="max-w-xl">
1573
+
<input
1574
+
type="button"
1575
+
name="galleryUri"
1576
+
value={gallery?.uri}
1577
+
class="hidden"
1256
1578
/>
1257
1579
</div>
1258
-
<div class="mb-2 relative">
1259
-
<label htmlFor="description">Description</label>
1260
-
<Textarea id="description" name="description" rows={4} class="input">
1261
-
{(gallery?.record as Gallery)?.description}
1262
-
</Textarea>
1263
-
</div>
1264
-
</form>
1265
-
<div class="max-w-xl">
1266
-
<input
1267
-
type="button"
1268
-
name="galleryUri"
1269
-
value={gallery?.uri}
1270
-
class="hidden"
1271
-
/>
1272
-
<Button variant="primary" class="mb-2" asChild>
1273
-
<label class="w-fit">
1274
-
<i class="fa fa-plus"></i> Add images
1275
-
<input
1276
-
class="hidden"
1277
-
type="file"
1278
-
multiple
1279
-
accept="image/*"
1280
-
_="on change
1281
-
set fileList to me.files
1282
-
if fileList.length > 10
1283
-
alert('You can only upload 10 images')
1284
-
halt
1285
-
end
1286
-
for file in fileList
1287
-
make a FormData called fd
1288
-
fd.append('file', file)
1289
-
fetch /actions/images/upload-start with { method:'POST', body:fd }
1290
-
then put it at the end of #image-preview
1291
-
then call htmx.process(#image-preview)
1292
-
end
1293
-
set me.value to ''"
1294
-
/>
1295
-
</label>
1296
-
</Button>
1297
-
<div id="image-preview" class="w-full h-full grid grid-cols-5 gap-2">
1298
-
{gallery?.images?.map((image) => (
1299
-
<ImagePreview key={image.cid} src={image.thumb} cid={image.cid} />
1300
-
))}
1301
-
</div>
1302
-
</div>
1303
-
<form id="delete-form" hx-post={`/actions/delete?uri=${gallery?.uri}`}>
1304
-
<input type="hidden" name="uri" value={gallery?.uri} />
1305
-
</form>
1306
-
<div class="flex flex-col gap-2 mt-2">
1307
-
<Button
1308
-
variant="primary"
1309
-
form="gallery-form"
1310
-
type="submit"
1311
-
class="w-fit"
1580
+
<form
1581
+
id="delete-form"
1582
+
hx-post={`/actions/gallery/delete?uri=${gallery?.uri}`}
1312
1583
>
1313
-
{gallery ? "Update gallery" : "Create gallery"}
1314
-
</Button>
1315
-
1316
-
{gallery
1317
-
? (
1318
-
<Button
1319
-
variant="destructive"
1320
-
form="delete-form"
1321
-
type="submit"
1322
-
class="w-fit"
1323
-
>
1324
-
Delete gallery
1325
-
</Button>
1326
-
)
1327
-
: null}
1328
-
</div>
1329
-
</div>
1584
+
<input type="hidden" name="uri" value={gallery?.uri} />
1585
+
</form>
1586
+
<div class="flex flex-col gap-2 mt-2">
1587
+
<Button
1588
+
variant="primary"
1589
+
form="gallery-form"
1590
+
type="submit"
1591
+
class="w-full"
1592
+
>
1593
+
{gallery ? "Update gallery" : "Create gallery"}
1594
+
</Button>
1595
+
{gallery
1596
+
? (
1597
+
<Button
1598
+
variant="destructive"
1599
+
form="delete-form"
1600
+
type="submit"
1601
+
class="w-full"
1602
+
>
1603
+
Delete gallery
1604
+
</Button>
1605
+
)
1606
+
: null}
1607
+
<Button
1608
+
variant="secondary"
1609
+
type="button"
1610
+
class="w-full"
1611
+
_={Dialog._closeOnClick}
1612
+
>
1613
+
Cancel
1614
+
</Button>
1615
+
</div>
1616
+
</Dialog.Content>
1617
+
</Dialog>
1330
1618
);
1331
1619
}
1332
1620
1333
-
function ImagePreview({
1621
+
function PhotoPreview({
1334
1622
src,
1335
-
cid,
1623
+
uri,
1336
1624
}: Readonly<{
1337
1625
src: string;
1338
-
cid?: string;
1626
+
uri?: string;
1339
1627
}>) {
1340
1628
return (
1341
-
<div class="relative">
1342
-
{cid
1629
+
<div class="relative aspect-square">
1630
+
{uri
1343
1631
? (
1344
1632
<button
1345
1633
type="button"
1346
-
class="bg-black/80 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center"
1347
-
_={`on click
1348
-
set input to <input[value='${cid}']/>
1349
-
if input exists
1350
-
remove input
1351
-
end
1352
-
remove me.parentNode
1353
-
halt
1354
-
`}
1634
+
hx-delete={`/actions/photo/${new AtUri(uri).rkey}`}
1635
+
class="bg-black/80 z-10 absolute top-0 right-0 cursor-pointer size-4 flex items-center justify-center"
1636
+
_="on htmx:afterOnLoad remove me.parentNode"
1355
1637
>
1356
1638
<i class="fas fa-close text-white"></i>
1357
1639
</button>
···
1360
1642
<img
1361
1643
src={src}
1362
1644
alt=""
1363
-
data-state={cid ? "complete" : "pending"}
1364
-
class="w-full h-full object-cover aspect-square data-[state=pending]:opacity-50"
1645
+
data-state={uri ? "complete" : "pending"}
1646
+
class="absolute inset-0 w-full h-full object-contain data-[state=pending]:opacity-50"
1365
1647
/>
1366
1648
</div>
1367
1649
);
···
1385
1667
);
1386
1668
}
1387
1669
1388
-
function ImageDialog({
1670
+
function PhotoDialog({
1389
1671
gallery,
1390
1672
image,
1391
1673
nextImage,
1392
1674
prevImage,
1393
1675
}: Readonly<{
1394
1676
gallery: GalleryView;
1395
-
image: ViewImage;
1396
-
nextImage?: ViewImage;
1397
-
prevImage?: ViewImage;
1677
+
image: PhotoView;
1678
+
nextImage?: PhotoView;
1679
+
prevImage?: PhotoView;
1398
1680
}>) {
1399
1681
return (
1400
-
<Dialog id="image-dialog" class="bg-black z-30">
1682
+
<Dialog id="photo-dialog" class="bg-black z-30">
1401
1683
{nextImage
1402
1684
? (
1403
1685
<div
1404
-
hx-get={imageDialogLink(gallery, nextImage)}
1686
+
hx-get={photoDialogLink(gallery, nextImage)}
1405
1687
hx-trigger="keyup[key=='ArrowRight'] from:body, swipeleft from:body"
1406
-
hx-target="#image-dialog"
1688
+
hx-target="#photo-dialog"
1407
1689
hx-swap="innerHTML"
1408
1690
/>
1409
1691
)
···
1411
1693
{prevImage
1412
1694
? (
1413
1695
<div
1414
-
hx-get={imageDialogLink(gallery, prevImage)}
1696
+
hx-get={photoDialogLink(gallery, prevImage)}
1415
1697
hx-trigger="keyup[key=='ArrowLeft'] from:body, swiperight from:body"
1416
-
hx-target="#image-dialog"
1698
+
hx-target="#photo-dialog"
1417
1699
hx-swap="innerHTML"
1418
1700
/>
1419
1701
)
···
1441
1723
);
1442
1724
}
1443
1725
1444
-
function ImageAltDialog({
1445
-
image,
1726
+
function PhotoAltDialog({
1727
+
photo,
1446
1728
galleryUri,
1447
1729
}: Readonly<{
1448
-
image: ViewImage;
1730
+
photo: PhotoView;
1449
1731
galleryUri: string;
1450
1732
}>) {
1451
1733
return (
1452
-
<Dialog id="image-alt-dialog" class="z-30">
1734
+
<Dialog id="photo-alt-dialog" class="z-30">
1453
1735
<Dialog.Content>
1454
1736
<Dialog.Title>Add alt text</Dialog.Title>
1455
1737
<div class="aspect-square relative bg-gray-100">
1456
1738
<img
1457
-
src={image.fullsize}
1458
-
alt={image.alt}
1739
+
src={photo.fullsize}
1740
+
alt={photo.alt}
1459
1741
class="absolute inset-0 w-full h-full object-contain"
1460
1742
/>
1461
1743
</div>
1462
1744
<form
1463
-
hx-post="/actions/image-alt"
1464
-
_="on htmx:afterOnLoad[successful] trigger closeDialog"
1745
+
hx-put={`/actions/photo/${new AtUri(photo.uri).rkey}`}
1746
+
_="on htmx:afterOnLoad trigger closeDialog"
1465
1747
>
1466
1748
<input type="hidden" name="galleryUri" value={galleryUri} />
1467
-
<input type="hidden" name="cid" value={image.cid} />
1749
+
<input type="hidden" name="cid" value={photo.cid} />
1468
1750
<div class="my-2">
1469
1751
<label htmlFor="alt">Descriptive alt text</label>
1470
1752
<Textarea
1471
1753
id="alt"
1472
1754
name="alt"
1473
1755
rows={4}
1474
-
defaultValue={image.alt}
1756
+
defaultValue={photo.alt}
1475
1757
placeholder="Alt text"
1476
1758
/>
1477
1759
</div>
···
1489
1771
);
1490
1772
}
1491
1773
1492
-
function UploadOob({ cid }: Readonly<{ cid: string }>) {
1774
+
function PhotoSelectDialog({
1775
+
galleryUri,
1776
+
itemUris,
1777
+
photos,
1778
+
}: Readonly<{
1779
+
galleryUri: string;
1780
+
itemUris: string[];
1781
+
photos: PhotoView[];
1782
+
}>) {
1493
1783
return (
1494
-
<div hx-swap-oob="beforeend:#image-cids">
1495
-
{cid ? <input key={cid} type="hidden" name="cids" value={cid} /> : null}
1496
-
</div>
1784
+
<Dialog id="photo-select-dialog" class="z-30">
1785
+
<Dialog.Content class="w-full max-w-5xl">
1786
+
<Dialog.Title>Add photos</Dialog.Title>
1787
+
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 my-4">
1788
+
{photos.map((photo) => (
1789
+
<PhotoSelectButton
1790
+
key={photo.cid}
1791
+
galleryUri={galleryUri}
1792
+
itemUris={itemUris}
1793
+
photo={photo}
1794
+
/>
1795
+
))}
1796
+
</div>
1797
+
<div class="w-full flex flex-col gap-2 mt-2">
1798
+
<Dialog.Close class="w-full">
1799
+
Close
1800
+
</Dialog.Close>
1801
+
</div>
1802
+
</Dialog.Content>
1803
+
</Dialog>
1804
+
);
1805
+
}
1806
+
1807
+
function PhotoSelectButton({
1808
+
galleryUri,
1809
+
itemUris,
1810
+
photo,
1811
+
}: Readonly<{
1812
+
galleryUri: string;
1813
+
itemUris: string[];
1814
+
photo: PhotoView;
1815
+
}>) {
1816
+
return (
1817
+
<button
1818
+
hx-put={`/actions/gallery/${new AtUri(galleryUri).rkey}/${
1819
+
itemUris.includes(photo.uri) ? "remove-photo" : "add-photo"
1820
+
}/${new AtUri(photo.uri).rkey}`}
1821
+
hx-swap="outerHTML"
1822
+
type="button"
1823
+
data-added={itemUris.includes(photo.uri) ? "true" : "false"}
1824
+
class="group cursor-pointer relative aspect-square data-[added=true]:ring-2 ring-sky-500 disabled:opacity-50"
1825
+
_={`on htmx:beforeRequest add @disabled to me
1826
+
then on htmx:afterOnLoad
1827
+
remove @disabled from me
1828
+
if @data-added == 'true'
1829
+
set @data-added to 'false'
1830
+
remove #photo-${new AtUri(photo.uri).rkey}
1831
+
else
1832
+
set @data-added to 'true'
1833
+
end`}
1834
+
>
1835
+
<div class="hidden group-data-[added=true]:block absolute top-2 right-2">
1836
+
<i class="fa-check fa-solid text-sky-500 z-10" />
1837
+
</div>
1838
+
<img
1839
+
src={photo.fullsize}
1840
+
alt={photo.alt}
1841
+
class="absolute inset-0 w-full h-full object-contain"
1842
+
/>
1843
+
</button>
1497
1844
);
1498
1845
}
1499
1846
···
1509
1856
function galleryToView(
1510
1857
record: WithBffMeta<Gallery>,
1511
1858
creator: Un$Typed<ProfileView>,
1859
+
items: Photo[],
1512
1860
): Un$Typed<GalleryView> {
1513
1861
return {
1514
1862
uri: record.uri,
1515
1863
cid: record.cid,
1516
1864
creator,
1517
1865
record,
1518
-
images: record?.images?.map((image) =>
1519
-
imageToView(new AtUri(record.uri).hostname, image)
1866
+
items: items?.map((item) => itemToView(record.did, item)).filter(
1867
+
isPhotoView,
1520
1868
),
1521
1869
indexedAt: record.indexedAt,
1522
1870
};
1523
1871
}
1524
1872
1525
-
function imageToView(did: string, image: GalleryImage): Un$Typed<ViewImage> {
1873
+
function itemToView(
1874
+
did: string,
1875
+
item: WithBffMeta<Photo> | {
1876
+
$type: string;
1877
+
},
1878
+
): Un$Typed<PhotoView> | undefined {
1879
+
if (isPhoto(item)) {
1880
+
return photoToView(did, item);
1881
+
}
1882
+
return undefined;
1883
+
}
1884
+
1885
+
function photoToView(
1886
+
did: string,
1887
+
photo: WithBffMeta<Photo>,
1888
+
): $Typed<PhotoView> {
1526
1889
return {
1527
-
cid: image.image.ref.toString(),
1890
+
$type: "social.grain.photo.defs#photoView",
1891
+
uri: photo.uri,
1892
+
cid: photo.photo.ref.toString(),
1528
1893
thumb:
1529
-
`https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${image.image.ref.toString()}@webp`,
1894
+
`https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${photo.photo.ref.toString()}@webp`,
1530
1895
fullsize:
1531
-
`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${image.image.ref.toString()}@webp`,
1532
-
alt: image.alt,
1533
-
aspectRatio: image.aspectRatio,
1896
+
`https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${photo.photo.ref.toString()}@webp`,
1897
+
alt: photo.alt,
1898
+
aspectRatio: photo.aspectRatio,
1534
1899
};
1535
1900
}
1536
1901
···
1557
1922
return `/profile/${handle}/${galleryRkey}`;
1558
1923
}
1559
1924
1560
-
function imageDialogLink(gallery: GalleryView, image: ViewImage) {
1925
+
function photoDialogLink(gallery: GalleryView, image: PhotoView) {
1561
1926
return `/dialogs/image?galleryUri=${gallery.uri}&imageCid=${image.cid}`;
1562
1927
}
1563
1928
1564
-
function mergeUniqueImages(
1565
-
existingImages: GalleryImage[] | undefined,
1566
-
newImages: GalleryImage[],
1567
-
validCids?: string[],
1568
-
): GalleryImage[] {
1569
-
if (!existingImages || existingImages.length === 0) {
1570
-
return validCids
1571
-
? newImages.filter((img) => validCids.includes(img.image.ref.toString()))
1572
-
: newImages;
1573
-
}
1574
-
const uniqueImagesMap = new Map<string, GalleryImage>();
1575
-
existingImages.forEach((img) => {
1576
-
const key = img.image.ref.toString();
1577
-
uniqueImagesMap.set(key, img);
1578
-
});
1579
-
newImages.forEach((img) => {
1580
-
const key = img.image.ref.toString();
1581
-
uniqueImagesMap.set(key, img);
1582
-
});
1583
-
const mergedImages = [...uniqueImagesMap.values()];
1584
-
return validCids
1585
-
? mergedImages.filter((img) => validCids.includes(img.image.ref.toString()))
1586
-
: mergedImages;
1587
-
}
1588
-
1589
1929
async function onSignedIn({ actor, ctx }: onSignedInArgs) {
1590
1930
await ctx.backfillCollections(
1591
1931
[actor.did],
···
1691
2031
};
1692
2032
}
1693
2033
1694
-
function uploadDone(
2034
+
function avatarUploadDone(
1695
2035
cb: (params: { dataUrl: string; cid: string }) => VNode,
1696
2036
): RouteHandler {
1697
2037
return (req, _params, ctx) => {
···
1708
2048
};
1709
2049
}
1710
2050
1711
-
function imageUploadRoutes(): BffMiddleware[] {
2051
+
function photoUploadDone(
2052
+
cb: (params: { dataUrl: string; uri: string }) => VNode,
2053
+
): RouteHandler {
2054
+
return async (req, _params, ctx) => {
2055
+
requireAuth(ctx);
2056
+
const url = new URL(req.url);
2057
+
const searchParams = new URLSearchParams(url.search);
2058
+
const uploadId = searchParams.get("uploadId");
2059
+
if (!uploadId) return ctx.next();
2060
+
const meta = ctx.blobMetaCache.get(uploadId);
2061
+
if (!meta?.dataUrl || !meta?.blobRef) return ctx.next();
2062
+
const photoUri = await ctx.createRecord<Photo>(
2063
+
"social.grain.photo",
2064
+
{
2065
+
photo: meta.blobRef,
2066
+
aspectRatio: meta.dimensions?.width && meta.dimensions?.height
2067
+
? {
2068
+
width: meta.dimensions.width,
2069
+
height: meta.dimensions.height,
2070
+
}
2071
+
: undefined,
2072
+
alt: "",
2073
+
createdAt: new Date().toISOString(),
2074
+
},
2075
+
);
2076
+
return ctx.html(
2077
+
cb({ dataUrl: meta.dataUrl, uri: photoUri }),
2078
+
);
2079
+
};
2080
+
}
2081
+
2082
+
function photoUploadRoutes(): BffMiddleware[] {
1712
2083
return [
1713
2084
route(
1714
-
`/actions/images/upload-start`,
2085
+
`/actions/photo/upload-start`,
1715
2086
["POST"],
1716
2087
uploadStart(
1717
-
"images",
1718
-
({ dataUrl }) => <ImagePreview src={dataUrl ?? ""} />,
2088
+
"photo",
2089
+
({ dataUrl }) => <PhotoPreview src={dataUrl ?? ""} />,
1719
2090
),
1720
2091
),
1721
2092
route(
1722
-
`/actions/images/upload-check-status`,
2093
+
`/actions/photo/upload-check-status`,
1723
2094
["GET"],
1724
2095
uploadCheckStatus(({ uploadId, dataUrl }) => (
1725
2096
<>
1726
2097
<input type="hidden" name="uploadId" value={uploadId} />
1727
-
<ImagePreview src={dataUrl} />
2098
+
<PhotoPreview src={dataUrl} />
1728
2099
</>
1729
2100
)),
1730
2101
),
1731
2102
route(
1732
-
`/actions/images/upload-done`,
2103
+
`/actions/photo/upload-done`,
1733
2104
["GET"],
1734
-
uploadDone(({ dataUrl, cid }) => (
1735
-
<>
1736
-
<UploadOob cid={cid} />
1737
-
<ImagePreview src={dataUrl} cid={cid} />
1738
-
</>
2105
+
photoUploadDone(({ dataUrl, uri }) => (
2106
+
<PhotoPreview
2107
+
src={dataUrl}
2108
+
uri={uri}
2109
+
/>
1739
2110
)),
1740
2111
),
1741
2112
];
···
1773
2144
route(
1774
2145
`/actions/avatar/upload-done`,
1775
2146
["GET"],
1776
-
uploadDone(({ dataUrl, cid }) => (
2147
+
avatarUploadDone(({ dataUrl, cid }) => (
1777
2148
<>
1778
2149
<div hx-swap-oob="innerHTML:#image-input">
1779
2150
<input type="hidden" name="avatarCid" value={cid} />
+2
-2
static/image_dialog.js
+2
-2
static/image_dialog.js
···
13
13
}
14
14
};
15
15
const observer = new MutationObserver(() => {
16
-
const modal = document.getElementById("image-dialog");
16
+
const modal = document.getElementById("photo-dialog");
17
17
if (!modal) {
18
18
console.log("Image Dialog not found, removing event listeners");
19
19
document.body.removeEventListener("touchstart", onTouchStart);
···
22
22
}
23
23
});
24
24
htmx.onLoad((evt) => {
25
-
if (evt.id === "image-dialog") {
25
+
if (evt.id === "photo-dialog") {
26
26
document.body.addEventListener("touchstart", onTouchStart);
27
27
document.body.addEventListener("touchend", onTouchEnd);
28
28
}
+121
-5
static/styles.css
+121
-5
static/styles.css
···
7
7
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
8
8
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
9
9
"Courier New", monospace;
10
+
--color-sky-500: oklch(68.5% 0.169 237.323);
10
11
--color-slate-800: oklch(27.9% 0.041 260.031);
11
12
--color-slate-900: oklch(20.8% 0.042 265.755);
12
13
--color-gray-100: oklch(96.7% 0.003 264.542);
···
201
202
.inset-0 {
202
203
inset: calc(var(--spacing) * 0);
203
204
}
205
+
.top-0 {
206
+
top: calc(var(--spacing) * 0);
207
+
}
204
208
.top-2 {
205
209
top: calc(var(--spacing) * 2);
206
210
}
···
248
252
}
249
253
.mr-1 {
250
254
margin-right: calc(var(--spacing) * 1);
255
+
}
256
+
.mr-2 {
257
+
margin-right: calc(var(--spacing) * 2);
251
258
}
252
259
.mb-2 {
253
260
margin-bottom: calc(var(--spacing) * 2);
···
323
330
.w-full {
324
331
width: 100%;
325
332
}
333
+
.max-w-5xl {
334
+
max-width: var(--container-5xl);
335
+
}
326
336
.max-w-md {
327
337
max-width: var(--container-md);
328
338
}
···
338
348
.grid-cols-1 {
339
349
grid-template-columns: repeat(1, minmax(0, 1fr));
340
350
}
341
-
.grid-cols-5 {
342
-
grid-template-columns: repeat(5, minmax(0, 1fr));
351
+
.grid-cols-2 {
352
+
grid-template-columns: repeat(2, minmax(0, 1fr));
343
353
}
344
354
.flex-col {
345
355
flex-direction: column;
···
350
360
.justify-center {
351
361
justify-content: center;
352
362
}
353
-
.gap-1 {
354
-
gap: calc(var(--spacing) * 1);
355
-
}
356
363
.gap-2 {
357
364
gap: calc(var(--spacing) * 2);
358
365
}
···
441
448
.py-\[1px\] {
442
449
padding-block: 1px;
443
450
}
451
+
.pt-4 {
452
+
padding-top: calc(var(--spacing) * 4);
453
+
}
444
454
.text-left {
445
455
text-align: left;
446
456
}
···
487
497
.text-gray-900 {
488
498
color: var(--color-gray-900);
489
499
}
500
+
.text-sky-500 {
501
+
color: var(--color-sky-500);
502
+
}
490
503
.text-white {
491
504
color: var(--color-white);
492
505
}
493
506
.lowercase {
494
507
text-transform: lowercase;
495
508
}
509
+
.ring-sky-500 {
510
+
--tw-ring-color: var(--color-sky-500);
511
+
}
512
+
.group-data-\[added\=true\]\:block {
513
+
&:is(:where(.group)[data-added="true"] *) {
514
+
display: block;
515
+
}
516
+
}
496
517
.hover\:underline {
497
518
&:hover {
498
519
@media (hover: hover) {
···
500
521
}
501
522
}
502
523
}
524
+
.disabled\:opacity-50 {
525
+
&:disabled {
526
+
opacity: 50%;
527
+
}
528
+
}
529
+
.data-\[added\=true\]\:ring-2 {
530
+
&[data-added="true"] {
531
+
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
532
+
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
533
+
}
534
+
}
503
535
.data-\[state\=pending\]\:opacity-50 {
504
536
&[data-state="pending"] {
505
537
opacity: 50%;
···
555
587
grid-template-columns: repeat(3, minmax(0, 1fr));
556
588
}
557
589
}
590
+
.sm\:grid-cols-5 {
591
+
@media (width >= 40rem) {
592
+
grid-template-columns: repeat(5, minmax(0, 1fr));
593
+
}
594
+
}
558
595
.sm\:flex-row {
559
596
@media (width >= 40rem) {
560
597
flex-direction: row;
···
610
647
syntax: "*";
611
648
inherits: false;
612
649
}
650
+
@property --tw-shadow {
651
+
syntax: "*";
652
+
inherits: false;
653
+
initial-value: 0 0 #0000;
654
+
}
655
+
@property --tw-shadow-color {
656
+
syntax: "*";
657
+
inherits: false;
658
+
}
659
+
@property --tw-shadow-alpha {
660
+
syntax: "<percentage>";
661
+
inherits: false;
662
+
initial-value: 100%;
663
+
}
664
+
@property --tw-inset-shadow {
665
+
syntax: "*";
666
+
inherits: false;
667
+
initial-value: 0 0 #0000;
668
+
}
669
+
@property --tw-inset-shadow-color {
670
+
syntax: "*";
671
+
inherits: false;
672
+
}
673
+
@property --tw-inset-shadow-alpha {
674
+
syntax: "<percentage>";
675
+
inherits: false;
676
+
initial-value: 100%;
677
+
}
678
+
@property --tw-ring-color {
679
+
syntax: "*";
680
+
inherits: false;
681
+
}
682
+
@property --tw-ring-shadow {
683
+
syntax: "*";
684
+
inherits: false;
685
+
initial-value: 0 0 #0000;
686
+
}
687
+
@property --tw-inset-ring-color {
688
+
syntax: "*";
689
+
inherits: false;
690
+
}
691
+
@property --tw-inset-ring-shadow {
692
+
syntax: "*";
693
+
inherits: false;
694
+
initial-value: 0 0 #0000;
695
+
}
696
+
@property --tw-ring-inset {
697
+
syntax: "*";
698
+
inherits: false;
699
+
}
700
+
@property --tw-ring-offset-width {
701
+
syntax: "<length>";
702
+
inherits: false;
703
+
initial-value: 0px;
704
+
}
705
+
@property --tw-ring-offset-color {
706
+
syntax: "*";
707
+
inherits: false;
708
+
initial-value: #fff;
709
+
}
710
+
@property --tw-ring-offset-shadow {
711
+
syntax: "*";
712
+
inherits: false;
713
+
initial-value: 0 0 #0000;
714
+
}
613
715
@layer properties {
614
716
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
615
717
*, ::before, ::after, ::backdrop {
···
617
719
--tw-space-x-reverse: 0;
618
720
--tw-border-style: solid;
619
721
--tw-font-weight: initial;
722
+
--tw-shadow: 0 0 #0000;
723
+
--tw-shadow-color: initial;
724
+
--tw-shadow-alpha: 100%;
725
+
--tw-inset-shadow: 0 0 #0000;
726
+
--tw-inset-shadow-color: initial;
727
+
--tw-inset-shadow-alpha: 100%;
728
+
--tw-ring-color: initial;
729
+
--tw-ring-shadow: 0 0 #0000;
730
+
--tw-inset-ring-color: initial;
731
+
--tw-inset-ring-shadow: 0 0 #0000;
732
+
--tw-ring-inset: initial;
733
+
--tw-ring-offset-width: 0px;
734
+
--tw-ring-offset-color: #fff;
735
+
--tw-ring-offset-shadow: 0 0 #0000;
620
736
}
621
737
}
622
738
}