grain.social is a photo sharing platform built on atproto.

new lexicon scheme, photos are records, split gallery details and photos

Changed files
+1334 -693
__generated__
lexicons
static
+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
··· 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
+28
__generated__/types/social/grain/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../lexicons.ts' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.ts' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'social.grain.defs' 12 + 13 + /** width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit. */ 14 + export interface AspectRatio { 15 + $type?: 'social.grain.defs#aspectRatio' 16 + width: number 17 + height: number 18 + } 19 + 20 + const hashAspectRatio = 'aspectRatio' 21 + 22 + export function isAspectRatio<V>(v: V) { 23 + return is$typed(v, id, hashAspectRatio) 24 + } 25 + 26 + export function validateAspectRatio<V>(v: V) { 27 + return validate<AspectRatio & V>(v, id, hashAspectRatio) 28 + }
+29
__generated__/types/social/grain/gallery.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../lexicons.ts' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.ts' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'social.grain.gallery' 12 + 13 + export interface Record { 14 + $type: 'social.grain.gallery' 15 + title: string 16 + description?: string 17 + createdAt: string 18 + [k: string]: unknown 19 + } 20 + 21 + const hashRecord = 'main' 22 + 23 + export function isRecord<V>(v: V) { 24 + return is$typed(v, id, hashRecord) 25 + } 26 + 27 + export function validateRecord<V>(v: V) { 28 + return validate<Record & V>(v, id, hashRecord, true) 29 + }
+37
__generated__/types/social/grain/gallery/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.ts' 12 + import type * as SocialGrainActorDefs from '../actor/defs.ts' 13 + import type * as SocialGrainPhotoDefs from '../photo/defs.ts' 14 + 15 + const is$typed = _is$typed, 16 + validate = _validate 17 + const id = 'social.grain.gallery.defs' 18 + 19 + export interface GalleryView { 20 + $type?: 'social.grain.gallery.defs#galleryView' 21 + uri: string 22 + cid: string 23 + creator: SocialGrainActorDefs.ProfileView 24 + record: { [_ in string]: unknown } 25 + items?: ($Typed<SocialGrainPhotoDefs.PhotoView> | { $type: string })[] 26 + indexedAt: string 27 + } 28 + 29 + const hashGalleryView = 'galleryView' 30 + 31 + export function isGalleryView<V>(v: V) { 32 + return is$typed(v, id, hashGalleryView) 33 + } 34 + 35 + export function validateGalleryView<V>(v: V) { 36 + return validate<GalleryView & V>(v, id, hashGalleryView) 37 + }
+32
__generated__/types/social/grain/photo.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../lexicons.ts' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.ts' 8 + import type * as SocialGrainDefs from './defs.ts' 9 + 10 + const is$typed = _is$typed, 11 + validate = _validate 12 + const id = 'social.grain.photo' 13 + 14 + export interface Record { 15 + $type: 'social.grain.photo' 16 + photo: BlobRef 17 + /** Alt text description of the image, for accessibility. */ 18 + alt: string 19 + aspectRatio?: SocialGrainDefs.AspectRatio 20 + createdAt?: string 21 + [k: string]: unknown 22 + } 23 + 24 + const hashRecord = 'main' 25 + 26 + export function isRecord<V>(v: V) { 27 + return is$typed(v, id, hashRecord) 28 + } 29 + 30 + export function validateRecord<V>(v: V) { 31 + return validate<Record & V>(v, id, hashRecord, true) 32 + }
+39
__generated__/types/social/grain/photo/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 + import { CID } from "npm:multiformats/cid" 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.ts' 12 + import type * as SocialGrainDefs from '../defs.ts' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'social.grain.photo.defs' 17 + 18 + export interface PhotoView { 19 + $type?: 'social.grain.photo.defs#photoView' 20 + uri: string 21 + cid: string 22 + /** Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View. */ 23 + thumb: string 24 + /** 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. */ 25 + fullsize: string 26 + /** Alt text description of the image, for accessibility. */ 27 + alt: string 28 + aspectRatio?: SocialGrainDefs.AspectRatio 29 + } 30 + 31 + const hashPhotoView = 'photoView' 32 + 33 + export function isPhotoView<V>(v: V) { 34 + return is$typed(v, id, hashPhotoView) 35 + } 36 + 37 + export function validatePhotoView<V>(v: V) { 38 + return validate<PhotoView & V>(v, id, hashPhotoView) 39 + }
+4 -4
__generated__/types/social/grain/v0/actor/defs.ts __generated__/types/social/grain/actor/defs.ts
··· 3 3 */ 4 4 import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 5 import { CID } from "npm:multiformats/cid" 6 - import { validate as _validate } from '../../../../../lexicons.ts' 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 7 import { 8 8 type $Typed, 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 - } from '../../../../../util.ts' 11 + } from '../../../../util.ts' 12 12 13 13 const is$typed = _is$typed, 14 14 validate = _validate 15 - const id = 'social.grain.v0.actor.defs' 15 + const id = 'social.grain.actor.defs' 16 16 17 17 export interface ProfileView { 18 - $type?: 'social.grain.v0.actor.defs#profileView' 18 + $type?: 'social.grain.actor.defs#profileView' 19 19 did: string 20 20 handle: string 21 21 displayName?: string
+4 -4
__generated__/types/social/grain/v0/actor/profile.ts __generated__/types/social/grain/actor/profile.ts
··· 3 3 */ 4 4 import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 5 import { CID } from "npm:multiformats/cid" 6 - import { validate as _validate } from '../../../../../lexicons.ts' 6 + import { validate as _validate } from '../../../../lexicons.ts' 7 7 import { 8 8 type $Typed, 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 - } from '../../../../../util.ts' 11 + } from '../../../../util.ts' 12 12 13 13 const is$typed = _is$typed, 14 14 validate = _validate 15 - const id = 'social.grain.v0.actor.profile' 15 + const id = 'social.grain.actor.profile' 16 16 17 17 export interface Record { 18 - $type: 'social.grain.v0.actor.profile' 18 + $type: 'social.grain.actor.profile' 19 19 displayName?: string 20 20 /** Free-form profile description text. */ 21 21 description?: string
+4 -6
__generated__/types/social/grain/v0/gallery.ts __generated__/types/social/grain/gallery/item.ts
··· 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 11 } from '../../../../util.ts' 12 - import type * as SocialGrainV0GalleryDefs from './gallery/defs.ts' 13 12 14 13 const is$typed = _is$typed, 15 14 validate = _validate 16 - const id = 'social.grain.v0.gallery' 15 + const id = 'social.grain.gallery.item' 17 16 18 17 export interface Record { 19 - $type: 'social.grain.v0.gallery' 20 - title: string 21 - description?: string 22 - images?: SocialGrainV0GalleryDefs.Image[] 18 + $type: 'social.grain.gallery.item' 23 19 createdAt: string 20 + gallery: string 21 + item: string 24 22 [k: string]: unknown 25 23 } 26 24
-93
__generated__/types/social/grain/v0/gallery/defs.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 - import { CID } from "npm:multiformats/cid" 6 - import { validate as _validate } from '../../../../../lexicons.ts' 7 - import { 8 - type $Typed, 9 - is$typed as _is$typed, 10 - type OmitKey, 11 - } from '../../../../../util.ts' 12 - import type * as SocialGrainV0ActorDefs from '../actor/defs.ts' 13 - 14 - const is$typed = _is$typed, 15 - validate = _validate 16 - const id = 'social.grain.v0.gallery.defs' 17 - 18 - /** width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit. */ 19 - export interface AspectRatio { 20 - $type?: 'social.grain.v0.gallery.defs#aspectRatio' 21 - width: number 22 - height: number 23 - } 24 - 25 - const hashAspectRatio = 'aspectRatio' 26 - 27 - export function isAspectRatio<V>(v: V) { 28 - return is$typed(v, id, hashAspectRatio) 29 - } 30 - 31 - export function validateAspectRatio<V>(v: V) { 32 - return validate<AspectRatio & V>(v, id, hashAspectRatio) 33 - } 34 - 35 - export interface GalleryView { 36 - $type?: 'social.grain.v0.gallery.defs#galleryView' 37 - uri: string 38 - cid: string 39 - creator: SocialGrainV0ActorDefs.ProfileView 40 - record: { [_ in string]: unknown } 41 - images?: ViewImage[] 42 - indexedAt: string 43 - } 44 - 45 - const hashGalleryView = 'galleryView' 46 - 47 - export function isGalleryView<V>(v: V) { 48 - return is$typed(v, id, hashGalleryView) 49 - } 50 - 51 - export function validateGalleryView<V>(v: V) { 52 - return validate<GalleryView & V>(v, id, hashGalleryView) 53 - } 54 - 55 - export interface Image { 56 - $type?: 'social.grain.v0.gallery.defs#image' 57 - image: BlobRef 58 - /** Alt text description of the image, for accessibility. */ 59 - alt: string 60 - aspectRatio?: AspectRatio 61 - } 62 - 63 - const hashImage = 'image' 64 - 65 - export function isImage<V>(v: V) { 66 - return is$typed(v, id, hashImage) 67 - } 68 - 69 - export function validateImage<V>(v: V) { 70 - return validate<Image & V>(v, id, hashImage) 71 - } 72 - 73 - export interface ViewImage { 74 - $type?: 'social.grain.v0.gallery.defs#viewImage' 75 - cid: string 76 - /** Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View. */ 77 - thumb: string 78 - /** 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. */ 79 - fullsize: string 80 - /** Alt text description of the image, for accessibility. */ 81 - alt: string 82 - aspectRatio?: AspectRatio 83 - } 84 - 85 - const hashViewImage = 'viewImage' 86 - 87 - export function isViewImage<V>(v: V) { 88 - return is$typed(v, id, hashViewImage) 89 - } 90 - 91 - export function validateViewImage<V>(v: V) { 92 - return validate<ViewImage & V>(v, id, hashViewImage) 93 - }
+4 -8
__generated__/types/social/grain/v0/gallery/star.ts __generated__/types/social/grain/favorite.ts
··· 3 3 */ 4 4 import { type ValidationResult, BlobRef } from "npm:@atproto/lexicon" 5 5 import { CID } from "npm:multiformats/cid" 6 - import { validate as _validate } from '../../../../../lexicons.ts' 7 - import { 8 - type $Typed, 9 - is$typed as _is$typed, 10 - type OmitKey, 11 - } from '../../../../../util.ts' 6 + import { validate as _validate } from '../../../lexicons.ts' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util.ts' 12 8 13 9 const is$typed = _is$typed, 14 10 validate = _validate 15 - const id = 'social.grain.v0.gallery.star' 11 + const id = 'social.grain.favorite' 16 12 17 13 export interface Record { 18 - $type: 'social.grain.v0.gallery.star' 14 + $type: 'social.grain.favorite' 19 15 createdAt: string 20 16 subject: string 21 17 [k: string]: unknown
+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
··· 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
··· 14 14 # BFF_QUEUE_DATABASE_URL = '/litefs/my.db' 15 15 BFF_LEXICON_DIR = './__generated__' 16 16 BFF_PORT = '8081' 17 - BFF_PUBLIC_URL = 'https://atphoto.fly.dev' 17 + BFF_PUBLIC_URL = 'https://grain.social' 18 18 19 19 [[mounts]] 20 20 source = "litefs"
+15
lexicons/social/grain/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.defs", 4 + "defs": { 5 + "aspectRatio": { 6 + "type": "object", 7 + "description": "width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.", 8 + "required": ["width", "height"], 9 + "properties": { 10 + "width": { "type": "integer", "minimum": 1 }, 11 + "height": { "type": "integer", "minimum": 1 } 12 + } 13 + } 14 + } 15 + }
+7 -58
lexicons/social/grain/gallery/defs.json
··· 2 2 "lexicon": 1, 3 3 "id": "social.grain.gallery.defs", 4 4 "defs": { 5 - "aspectRatio": { 6 - "type": "object", 7 - "description": "width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.", 8 - "required": ["width", "height"], 9 - "properties": { 10 - "width": { "type": "integer", "minimum": 1 }, 11 - "height": { "type": "integer", "minimum": 1 } 12 - } 13 - }, 14 5 "galleryView": { 15 6 "type": "object", 16 7 "required": ["uri", "cid", "creator", "record", "indexedAt"], ··· 22 13 "ref": "social.grain.actor.defs#profileView" 23 14 }, 24 15 "record": { "type": "unknown" }, 25 - "images": { 16 + "items": { 26 17 "type": "array", 27 - "items": { "type": "ref", "ref": "#viewImage" } 18 + "items": { 19 + "type": "union", 20 + "refs": [ 21 + "social.grain.photo.defs#photoView" 22 + ] 23 + } 28 24 }, 29 25 "indexedAt": { "type": "string", "format": "datetime" } 30 - } 31 - }, 32 - "image": { 33 - "type": "object", 34 - "required": ["image", "alt"], 35 - "properties": { 36 - "image": { 37 - "type": "blob", 38 - "accept": ["image/*"], 39 - "maxSize": 1000000 40 - }, 41 - "alt": { 42 - "type": "string", 43 - "description": "Alt text description of the image, for accessibility." 44 - }, 45 - "aspectRatio": { 46 - "type": "ref", 47 - "ref": "social.grain.gallery.defs#aspectRatio" 48 - } 49 - } 50 - }, 51 - "viewImage": { 52 - "type": "object", 53 - "required": ["cid", "thumb", "fullsize", "alt"], 54 - "properties": { 55 - "cid": { 56 - "type": "string", 57 - "format": "cid" 58 - }, 59 - "thumb": { 60 - "type": "string", 61 - "format": "uri", 62 - "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." 63 - }, 64 - "fullsize": { 65 - "type": "string", 66 - "format": "uri", 67 - "description": "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." 68 - }, 69 - "alt": { 70 - "type": "string", 71 - "description": "Alt text description of the image, for accessibility." 72 - }, 73 - "aspectRatio": { 74 - "type": "ref", 75 - "ref": "social.grain.gallery.defs#aspectRatio" 76 - } 77 26 } 78 27 } 79 28 }
-8
lexicons/social/grain/gallery/gallery.json
··· 11 11 "properties": { 12 12 "title": { "type": "string", "maxLength": 100 }, 13 13 "description": { "type": "string", "maxLength": 1000 }, 14 - "images": { 15 - "type": "array", 16 - "items": { 17 - "type": "ref", 18 - "ref": "social.grain.gallery.defs#image" 19 - }, 20 - "maxLength": 10 21 - }, 22 14 "createdAt": { "type": "string", "format": "datetime" } 23 15 } 24 16 }
+28
lexicons/social/grain/gallery/item.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery.item", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["createdAt", "gallery", "item"], 11 + "properties": { 12 + "createdAt": { 13 + "type": "string", 14 + "format": "datetime" 15 + }, 16 + "gallery": { 17 + "type": "string", 18 + "format": "at-uri" 19 + }, 20 + "item": { 21 + "type": "string", 22 + "format": "at-uri" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + }
+2 -5
lexicons/social/grain/gallery/star.json lexicons/social/grain/favorite.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "social.grain.gallery.star", 3 + "id": "social.grain.favorite", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 7 "key": "tid", 8 8 "record": { 9 9 "type": "object", 10 - "required": [ 11 - "createdAt", 12 - "subject" 13 - ], 10 + "required": ["createdAt", "subject"], 14 11 "properties": { 15 12 "createdAt": { 16 13 "type": "string",
+32
lexicons/social/grain/photo/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo.defs", 4 + "defs": { 5 + "photoView": { 6 + "type": "object", 7 + "required": ["uri", "cid", "thumb", "fullsize", "alt"], 8 + "properties": { 9 + "uri": { "type": "string", "format": "at-uri" }, 10 + "cid": { "type": "string", "format": "cid" }, 11 + "thumb": { 12 + "type": "string", 13 + "format": "uri", 14 + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." 15 + }, 16 + "fullsize": { 17 + "type": "string", 18 + "format": "uri", 19 + "description": "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." 20 + }, 21 + "alt": { 22 + "type": "string", 23 + "description": "Alt text description of the image, for accessibility." 24 + }, 25 + "aspectRatio": { 26 + "type": "ref", 27 + "ref": "social.grain.defs#aspectRatio" 28 + } 29 + } 30 + } 31 + } 32 + }
+30
lexicons/social/grain/photo/photo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["photo", "alt"], 11 + "properties": { 12 + "photo": { 13 + "type": "blob", 14 + "accept": ["image/*"], 15 + "maxSize": 1000000 16 + }, 17 + "alt": { 18 + "type": "string", 19 + "description": "Alt text description of the image, for accessibility." 20 + }, 21 + "aspectRatio": { 22 + "type": "ref", 23 + "ref": "social.grain.defs#aspectRatio" 24 + }, 25 + "createdAt": { "type": "string", "format": "datetime" } 26 + } 27 + } 28 + } 29 + } 30 + }
+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
··· 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
··· 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 }