grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
50
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+1334 -693
+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 }