+2
.env.example
+2
.env.example
+2
README.md
+2
README.md
+1
-1
deno.json
+1
-1
deno.json
···
2
2
"imports": {
3
3
"$lexicon/": "./__generated__/",
4
4
"@atproto/syntax": "npm:@atproto/syntax@^0.4.0",
5
-
"@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.38",
5
+
"@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.39",
6
6
"@std/http": "jsr:@std/http@^1.0.17",
7
7
"@std/path": "jsr:@std/path@^1.0.9",
8
8
"@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+35
-8
deno.lock
+35
-8
deno.lock
···
2
2
"version": "5",
3
3
"specifiers": {
4
4
"jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0",
5
-
"jsr:@bigmoves/bff@0.3.0-beta.38": "0.3.0-beta.38",
5
+
"jsr:@bigmoves/bff@0.3.0-beta.39": "0.3.0-beta.39",
6
6
"jsr:@deno/gfm@0.10": "0.10.0",
7
7
"jsr:@denosaurs/emoji@0.3": "0.3.1",
8
8
"jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1",
···
33
33
"jsr:@std/streams@^1.0.10": "1.0.10",
34
34
"jsr:@std/testing@^1.0.11": "1.0.11",
35
35
"npm:@atproto-labs/handle-resolver-node@~0.1.14": "0.1.16",
36
+
"npm:@atproto-labs/simple-store@~0.1.2": "0.1.2",
36
37
"npm:@atproto/api@~0.15.7": "0.15.15",
37
38
"npm:@atproto/common@~0.4.10": "0.4.11",
38
39
"npm:@atproto/identity@~0.4.7": "0.4.8",
···
41
42
"npm:@atproto/lexicon@0.4.11": "0.4.11",
42
43
"npm:@atproto/lexicon@~0.4.11": "0.4.11",
43
44
"npm:@atproto/oauth-client@~0.3.13": "0.3.22",
45
+
"npm:@atproto/oauth-types@~0.2.4": "0.2.8",
44
46
"npm:@atproto/syntax@0.4": "0.4.0",
45
47
"npm:@atproto/xrpc-server@*": "0.7.19",
48
+
"npm:@atproto/xrpc-server@0.7.18": "0.7.18",
46
49
"npm:@tailwindcss/cli@*": "4.1.9",
47
50
"npm:@tailwindcss/cli@^4.0.12": "4.1.9",
48
51
"npm:@tailwindcss/cli@^4.1.3": "4.1.9",
···
81
84
"integrity": "5c3ca124dd52eff51dace83790779ebe48c4b41559b799e16c8750bd415f2124",
82
85
"dependencies": [
83
86
"npm:@atproto-labs/handle-resolver-node",
87
+
"npm:@atproto-labs/simple-store",
84
88
"npm:@atproto/jwk",
85
89
"npm:@atproto/oauth-client",
90
+
"npm:@atproto/oauth-types",
86
91
"npm:jose"
87
92
]
88
93
},
89
-
"@bigmoves/bff@0.3.0-beta.38": {
90
-
"integrity": "74b8b77f451a00b16c9a43ce776bdac87753429bf2d15bf6c81b5eda1e2f9e0e",
94
+
"@bigmoves/bff@0.3.0-beta.39": {
95
+
"integrity": "6841d6b04aa91f5c79b8530af1d2721c0f1bf5b4038270da528121f3ab57765f",
91
96
"dependencies": [
92
97
"jsr:@bigmoves/atproto-oauth-client",
93
98
"jsr:@std/assert@^1.0.13",
···
98
103
"npm:@atproto/api",
99
104
"npm:@atproto/common",
100
105
"npm:@atproto/identity",
106
+
"npm:@atproto/lexicon@0.4.11",
101
107
"npm:@atproto/lexicon@~0.4.11",
102
108
"npm:@atproto/oauth-client",
103
109
"npm:@atproto/syntax",
110
+
"npm:@atproto/xrpc-server@0.7.18",
104
111
"npm:clsx",
105
112
"npm:multiformats@^13.3.2",
106
113
"npm:preact",
···
227
234
"dependencies": [
228
235
"@atproto-labs/fetch",
229
236
"@atproto-labs/pipe",
230
-
"@atproto-labs/simple-store",
237
+
"@atproto-labs/simple-store@0.2.0",
231
238
"@atproto-labs/simple-store-memory",
232
239
"@atproto/did",
233
240
"zod"
···
259
266
"@atproto-labs/handle-resolver@0.1.8": {
260
267
"integrity": "sha512-Y0ckccoCGDo/3g4thPkgp9QcORmc+qqEaCBCYCZYtfLIQp4775u22wd+4fyEyJP4DqoReKacninkICgRGfs3dQ==",
261
268
"dependencies": [
262
-
"@atproto-labs/simple-store",
269
+
"@atproto-labs/simple-store@0.2.0",
263
270
"@atproto-labs/simple-store-memory",
264
271
"@atproto/did",
265
272
"zod"
···
279
286
"@atproto-labs/simple-store-memory@0.1.3": {
280
287
"integrity": "sha512-jkitT9+AtU+0b28DoN92iURLaCt/q/q4yX8q6V+9LSwYlUTqKoj/5NFKvF7x6EBuG+gpUdlcycbH7e60gjOhRQ==",
281
288
"dependencies": [
282
-
"@atproto-labs/simple-store",
289
+
"@atproto-labs/simple-store@0.2.0",
283
290
"lru-cache"
284
291
]
285
292
},
293
+
"@atproto-labs/simple-store@0.1.2": {
294
+
"integrity": "sha512-9vTNvyPPBs44tKVFht16wGlilW8u4wpEtKwLkWbuNEh3h9TTQ8zjVhEoGZh/v73G4Otr9JUOSIq+/5+8OZD2mQ=="
295
+
},
286
296
"@atproto-labs/simple-store@0.2.0": {
287
297
"integrity": "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA=="
288
298
},
···
371
381
"@atproto-labs/fetch",
372
382
"@atproto-labs/handle-resolver",
373
383
"@atproto-labs/identity-resolver",
374
-
"@atproto-labs/simple-store",
384
+
"@atproto-labs/simple-store@0.2.0",
375
385
"@atproto-labs/simple-store-memory",
376
386
"@atproto/did",
377
387
"@atproto/jwk@0.2.0",
···
390
400
},
391
401
"@atproto/syntax@0.4.0": {
392
402
"integrity": "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA=="
403
+
},
404
+
"@atproto/xrpc-server@0.7.18": {
405
+
"integrity": "sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag==",
406
+
"dependencies": [
407
+
"@atproto/common",
408
+
"@atproto/crypto",
409
+
"@atproto/lexicon",
410
+
"@atproto/xrpc",
411
+
"cbor-x",
412
+
"express",
413
+
"http-errors",
414
+
"mime-types",
415
+
"rate-limiter-flexible",
416
+
"uint8arrays",
417
+
"ws",
418
+
"zod"
419
+
]
393
420
},
394
421
"@atproto/xrpc-server@0.7.19": {
395
422
"integrity": "sha512-YSCl/tU2NDykgDYslFSOYCr96esUgDwncFiADKL59/fyIFPLoT0qY8Uq/budpxUh0qPzjow4HHgVWESOaOpUmA==",
···
1883
1910
},
1884
1911
"workspace": {
1885
1912
"dependencies": [
1886
-
"jsr:@bigmoves/bff@0.3.0-beta.38",
1913
+
"jsr:@bigmoves/bff@0.3.0-beta.39",
1887
1914
"jsr:@std/http@^1.0.17",
1888
1915
"jsr:@std/path@^1.0.9",
1889
1916
"npm:@atproto/syntax@0.4",
+1
fly.toml
+1
fly.toml
···
13
13
BFF_DATABASE_URL = '/litefs/sqlite.db'
14
14
BFF_PORT = '8081'
15
15
BFF_PUBLIC_URL = 'https://grain.social'
16
+
BFF_JETSTREAM_URL = 'wss://jetstream1.us-west.bsky.network'
16
17
GOATCOUNTER_URL = 'https://grain.goatcounter.com/count'
17
18
USE_CDN = 'true'
18
19
PDS_HOST_URL = 'https://ansel.grainsocial.network'
+1
-1
src/components/AltTextButton.tsx
+1
-1
src/components/AltTextButton.tsx
···
4
4
return (
5
5
<button
6
6
type="button"
7
-
class="bg-zinc-950 dark:bg-zinc-950 py-[1px] px-[3px] absolute top-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10"
7
+
class="bg-zinc-950/50 dark:bg-zinc-950/50 py-[1px] px-[3px] absolute top-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10"
8
8
hx-get={`/dialogs/photo/${new AtUri(photoUri).rkey}/alt`}
9
9
hx-trigger="click"
10
10
hx-target="#layout"
+1
-1
src/components/AvatarButton.tsx
+1
-1
src/components/AvatarButton.tsx
+2
-2
src/components/AvatarDialog.tsx
+2
-2
src/components/AvatarDialog.tsx
···
1
1
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
2
2
import { Un$Typed } from "$lexicon/util.ts";
3
-
import { Dialog } from "@bigmoves/bff/components";
4
3
import { ActorAvatar } from "./ActorAvatar.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
5
5
6
6
export function AvatarDialog({
7
7
profile,
8
8
}: Readonly<{ profile: Un$Typed<ProfileView> }>) {
9
9
return (
10
-
<Dialog class="z-100">
10
+
<Dialog>
11
11
<Dialog.X />
12
12
<div
13
13
class="w-[400px] h-[400px] flex flex-col p-4 z-10"
+10
-2
src/components/Breadcrumb.tsx
+10
-2
src/components/Breadcrumb.tsx
···
1
+
import { cn } from "@bigmoves/bff/components";
2
+
1
3
type BreadcrumbItem = {
2
4
label: string;
3
5
href?: string;
4
6
};
5
7
6
-
export function Breadcrumb({ items }: Readonly<{ items: BreadcrumbItem[] }>) {
8
+
export function Breadcrumb(
9
+
{ class: classProp, items }: Readonly<
10
+
{ class?: string; items: BreadcrumbItem[] }
11
+
>,
12
+
) {
7
13
return (
8
-
<nav className="mb-4 text-sm text-zinc-500 dark:text-zinc-300">
14
+
<nav
15
+
className={cn("mb-4 text-sm text-zinc-500 dark:text-zinc-300", classProp)}
16
+
>
9
17
{items.map((item, idx) => (
10
18
<>
11
19
{item.href
+33
src/components/Button.tsx
+33
src/components/Button.tsx
···
1
+
import { cn } from "@bigmoves/bff/components";
2
+
import { cloneElement, type JSX } from "preact";
3
+
4
+
export type ButtonProps =
5
+
& JSX.ButtonHTMLAttributes<HTMLButtonElement>
6
+
& Readonly<{
7
+
variant?: "primary" | "secondary" | "ghost" | "destructive" | "tab";
8
+
asChild?: boolean;
9
+
}>;
10
+
11
+
export function Button(props: ButtonProps): JSX.Element {
12
+
const { variant, class: classProp, asChild, children, ...rest } = props;
13
+
const className = cn(
14
+
"grain-btn",
15
+
variant === "primary" && "grain-btn-primary",
16
+
variant === "secondary" && "grain-btn-secondary",
17
+
variant === "ghost" && "grain-btn-ghost",
18
+
variant === "destructive" && "grain-btn-destructive",
19
+
variant === "tab" && "grain-btn-tab",
20
+
classProp,
21
+
);
22
+
if (
23
+
asChild && children && typeof children === "object" && children !== null &&
24
+
"type" in children
25
+
) {
26
+
return cloneElement(children, {
27
+
...rest,
28
+
...children.props,
29
+
class: cn(className, children.props.class),
30
+
});
31
+
}
32
+
return <button class={className} {...rest}>{children}</button>;
33
+
}
+2
-2
src/components/CameraBadges.tsx
+2
-2
src/components/CameraBadges.tsx
···
7
7
) {
8
8
if (cameras.length === 0) return null;
9
9
return (
10
-
<div class={cn("flex gap-1", classProp)} id="camera-badges">
10
+
<div class={cn("flex flex-wrap gap-1", classProp)} id="camera-badges">
11
11
{cameras.sort().map((camera) => (
12
-
<span class="text-xs font-semibold bg-zinc-100 dark:bg-zinc-800 w-fit px-1">
12
+
<span class="text-xs font-semibold bg-zinc-100 dark:bg-zinc-800 w-fit px-2 py-1 rounded-full">
13
13
📷 {camera}
14
14
</span>
15
15
))}
+4
-3
src/components/CreateAccountDialog.tsx
+4
-3
src/components/CreateAccountDialog.tsx
···
1
1
import { OAUTH_ROUTES } from "@bigmoves/bff";
2
-
import { Button, Dialog } from "@bigmoves/bff/components";
3
2
import { PDS_HOST_URL } from "../env.ts";
3
+
import { Button } from "./Button.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
4
5
5
6
export function CreateAccountDialog({}: Readonly<{}>) {
6
7
return (
7
-
<Dialog id="photo-alt-dialog" class="z-100">
8
-
<Dialog.Content class="dark:bg-zinc-950 relative">
8
+
<Dialog id="photo-alt-dialog">
9
+
<Dialog.Content>
9
10
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
10
11
<Dialog.Title>Choose your handle</Dialog.Title>
11
12
<div className="flex flex-col space-y-4 my-10">
+133
src/components/Dialog.tsx
+133
src/components/Dialog.tsx
···
1
+
import { cn } from "@bigmoves/bff/components";
2
+
import type { FunctionalComponent, JSX } from "preact";
3
+
import { Button, ButtonProps } from "./Button.tsx";
4
+
5
+
type DialogProps = JSX.HTMLAttributes<HTMLDivElement> & { _?: string } & {
6
+
children: preact.ComponentChildren;
7
+
};
8
+
9
+
type DialogContentProps = JSX.HTMLAttributes<HTMLDivElement> & {
10
+
children: preact.ComponentChildren;
11
+
};
12
+
13
+
type DialogTitleProps = {
14
+
children: preact.ComponentChildren;
15
+
};
16
+
17
+
type DialogCloseProps = JSX.HTMLAttributes<HTMLButtonElement> & ButtonProps & {
18
+
children: preact.ComponentChildren;
19
+
};
20
+
21
+
type DialogXProps = JSX.HTMLAttributes<HTMLButtonElement>;
22
+
23
+
const _closeOnClick = "on click trigger closeDialog";
24
+
25
+
const Dialog: FunctionalComponent<DialogProps> & {
26
+
Content: FunctionalComponent<DialogContentProps>;
27
+
Title: FunctionalComponent<DialogTitleProps>;
28
+
Close: FunctionalComponent<DialogCloseProps>;
29
+
X: FunctionalComponent<DialogXProps>;
30
+
_closeOnClick: string;
31
+
} = ({ children, class: classProp, _ = "", ...props }) => {
32
+
return (
33
+
<div
34
+
class={cn(
35
+
"fixed top-0 bottom-0 right-0 left-0 flex items-center justify-center z-100",
36
+
classProp,
37
+
)}
38
+
{...{
39
+
_: `on closeDialog
40
+
remove me
41
+
remove .tw:pointer-events-none from document.body
42
+
remove [@data-scroll-locked] from document.body
43
+
on keyup[key is 'Escape'] from <body/> trigger closeDialog
44
+
init
45
+
add .tw:pointer-events-none to document.body
46
+
add .tw:pointer-events-auto to me
47
+
add [@data-scroll-locked=true] to document.body
48
+
${_}`,
49
+
}}
50
+
{...props}
51
+
>
52
+
<div
53
+
class="absolute top-0 left-0 right-0 bottom-0 bg-black/80"
54
+
{...{
55
+
_: _closeOnClick,
56
+
}}
57
+
/>
58
+
{children}
59
+
</div>
60
+
);
61
+
};
62
+
63
+
const DialogContent: FunctionalComponent<DialogContentProps> = (
64
+
{ children, class: classProp, ...props },
65
+
) => {
66
+
return (
67
+
<div
68
+
class={cn(
69
+
"w-[400px] bg-white dark:bg-zinc-900 rounded-md flex flex-col p-4 max-h-[calc(100vh-100px)] overflow-y-auto z-20 relative",
70
+
classProp,
71
+
)}
72
+
{...props}
73
+
>
74
+
{children}
75
+
</div>
76
+
);
77
+
};
78
+
79
+
const DialogTitle: FunctionalComponent<DialogTitleProps> = ({ children }) => {
80
+
return (
81
+
<h1 class="text-lg font-semibold text-center w-full mb-2">
82
+
{children}
83
+
</h1>
84
+
);
85
+
};
86
+
87
+
const DialogX: FunctionalComponent<DialogXProps> = ({
88
+
class: classProp,
89
+
}) => {
90
+
return (
91
+
<button
92
+
type="button"
93
+
class={cn(
94
+
"absolute top-4 right-4 h-4 w-4 cursor-pointer z-30 fill-white",
95
+
classProp,
96
+
)}
97
+
{...{
98
+
_: _closeOnClick,
99
+
}}
100
+
>
101
+
<svg
102
+
xmlns="http://www.w3.org/2000/svg"
103
+
viewBox="0 0 384 512"
104
+
>
105
+
{/* <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->*/}
106
+
<path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z" />
107
+
</svg>
108
+
</button>
109
+
);
110
+
};
111
+
112
+
const DialogClose: FunctionalComponent<DialogCloseProps> = (
113
+
{ children, ...props },
114
+
) => {
115
+
return (
116
+
<Button
117
+
{...{
118
+
_: _closeOnClick,
119
+
}}
120
+
{...props}
121
+
>
122
+
{children}
123
+
</Button>
124
+
);
125
+
};
126
+
127
+
Dialog.Content = DialogContent;
128
+
Dialog.Title = DialogTitle;
129
+
Dialog.Close = DialogClose;
130
+
Dialog.X = DialogX;
131
+
Dialog._closeOnClick = _closeOnClick;
132
+
133
+
export { Dialog };
+91
src/components/EditGalleryDialog.tsx
+91
src/components/EditGalleryDialog.tsx
···
1
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
2
+
import { AtUri } from "@atproto/syntax";
3
+
import { Button } from "./Button.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
5
+
6
+
export function EditGalleryDialog({ gallery }: Readonly<{
7
+
gallery: GalleryView;
8
+
}>) {
9
+
const rkey = new AtUri(gallery.uri).rkey;
10
+
return (
11
+
<Dialog>
12
+
<Dialog.Content class="gap-4">
13
+
<Dialog.Title>Edit gallery</Dialog.Title>
14
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
15
+
16
+
<ul class="divide-y divide-zinc-200 dark:divide-zinc-800 border-t border-b border-zinc-200 dark:border-zinc-800">
17
+
<li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800">
18
+
<button
19
+
type="button"
20
+
class="flex flex-col justify-start items-start text-left w-full px-2 py-4 cursor-pointer"
21
+
hx-get={`/dialogs/gallery/${rkey}/edit`}
22
+
hx-target="#dialog-target"
23
+
hx-swap="innerHTML"
24
+
>
25
+
Edit details
26
+
<div class="text-sm text-zinc-600 dark:text-zinc-500">
27
+
Update title, description, etc
28
+
</div>
29
+
</button>
30
+
</li>
31
+
<li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800">
32
+
<button
33
+
type="button"
34
+
class="flex flex-col justify-start items-start text-left w-full px-2 py-4 cursor-pointer"
35
+
hx-get={`/dialogs/gallery/${rkey}/photos`}
36
+
hx-target="#dialog-target"
37
+
hx-swap="innerHTML"
38
+
>
39
+
Edit photos
40
+
<div class="text-sm text-zinc-600 dark:text-zinc-500">
41
+
Upload photos, add from library, or remove photos
42
+
</div>
43
+
</button>
44
+
</li>
45
+
<li class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800">
46
+
<button
47
+
type="button"
48
+
hx-get={`/dialogs/gallery/${rkey}/sort`}
49
+
hx-target="#dialog-target"
50
+
hx-swap="innerHTML"
51
+
class="flex justify-between items-center text-left w-full px-2 py-4 cursor-pointer"
52
+
>
53
+
Change sort order
54
+
</button>
55
+
</li>
56
+
</ul>
57
+
<form
58
+
id="delete-form"
59
+
hx-post={`/actions/gallery/delete`}
60
+
hx-confirm="Are you sure you want to delete this gallery? This action cannot be undone. Photos in the gallery will not be deleted."
61
+
>
62
+
<input type="hidden" name="uri" value={gallery?.uri} />
63
+
</form>
64
+
<Button
65
+
variant="destructive"
66
+
form="delete-form"
67
+
>
68
+
Delete gallery
69
+
</Button>
70
+
</Dialog.Content>
71
+
</Dialog>
72
+
);
73
+
}
74
+
75
+
export function EditGalleryButton({ gallery }: Readonly<{
76
+
gallery: GalleryView;
77
+
}>) {
78
+
const rkey = new AtUri(gallery.uri).rkey;
79
+
return (
80
+
<Button
81
+
type="button"
82
+
variant="primary"
83
+
hx-get={`/dialogs/gallery/${rkey}`}
84
+
hx-trigger="click"
85
+
hx-target="#dialog-target"
86
+
hx-swap="innerHTML"
87
+
>
88
+
Edit gallery
89
+
</Button>
90
+
);
91
+
}
+4
-3
src/components/ExifInfoDialog.tsx
+4
-3
src/components/ExifInfoDialog.tsx
···
1
-
import { Dialog } from "@bigmoves/bff/components";
1
+
import { Dialog } from "./Dialog.tsx";
2
2
3
3
export function ExifInfoDialog() {
4
4
return (
5
-
<Dialog class="z-100">
6
-
<Dialog.Content class="dark:bg-zinc-950 relative">
5
+
<Dialog class="z-101">
6
+
<Dialog.Content class="flex flex-col gap-4">
7
7
<Dialog.Title>EXIF Info</Dialog.Title>
8
8
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
9
9
<div class="text-sm mt-2">
···
70
70
.
71
71
</p>
72
72
</div>
73
+
<Dialog.Close variant="secondary">Close</Dialog.Close>
73
74
</Dialog.Content>
74
75
</Dialog>
75
76
);
+2
-2
src/components/ExifOverlayDialog.tsx
+2
-2
src/components/ExifOverlayDialog.tsx
···
1
1
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
-
import { Dialog } from "@bigmoves/bff/components";
3
2
import { getOrderedExifData } from "../lib/photo.ts";
3
+
import { Dialog } from "./Dialog.tsx";
4
4
5
5
export function ExifOverlayDialog({
6
6
photo,
···
10
10
return (
11
11
<Dialog class="z-101">
12
12
<Dialog.Content
13
-
class="bg-transparent text-zinc-50 relative"
13
+
class="bg-transparent! text-zinc-50 relative"
14
14
_={Dialog._closeOnClick}
15
15
>
16
16
<Dialog.Title>Camera Settings</Dialog.Title>
+2
-1
src/components/FavoriteButton.tsx
+2
-1
src/components/FavoriteButton.tsx
···
1
1
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
2
2
import { AtUri } from "@atproto/syntax";
3
3
import { WithBffMeta } from "@bigmoves/bff";
4
-
import { Button, cn } from "@bigmoves/bff/components";
4
+
import { cn } from "@bigmoves/bff/components";
5
+
import { Button } from "./Button.tsx";
5
6
6
7
export function FavoriteButton({
7
8
currentUserDid,
+2
-1
src/components/FollowButton.tsx
+2
-1
src/components/FollowButton.tsx
+10
-24
src/components/GalleryCreateEditDialog.tsx
src/components/GalleryDetailsDialog.tsx
+10
-24
src/components/GalleryCreateEditDialog.tsx
src/components/GalleryDetailsDialog.tsx
···
1
1
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
2
2
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
3
-
import { Button, Dialog, Input, Textarea } from "@bigmoves/bff/components";
3
+
import { Button } from "./Button.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
5
+
import { Input } from "./Input.tsx";
6
+
import { Label } from "./Label.tsx";
7
+
import { Textarea } from "./Textarea.tsx";
4
8
5
-
export function GalleryCreateEditDialog({
9
+
export function GalleryDetailsDialog({
6
10
gallery,
7
11
}: Readonly<{ gallery?: GalleryView | null }>) {
8
12
return (
9
-
<Dialog id="gallery-dialog" class="z-100">
10
-
<Dialog.Content class="dark:bg-zinc-950 relative">
13
+
<Dialog id="gallery-dialog">
14
+
<Dialog.Content>
11
15
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
12
16
<Dialog.Title>
13
17
{gallery ? "Edit gallery" : "Create a new gallery"}
···
24
28
alert('Error: ' + event.detail.xhr.responseText)"
25
29
>
26
30
<div class="mb-4 relative">
27
-
<label htmlFor="title">Gallery name</label>
31
+
<Label htmlFor="title">Gallery name</Label>
28
32
<Input
29
33
type="text"
30
34
id="title"
···
36
40
/>
37
41
</div>
38
42
<div class="mb-2 relative">
39
-
<label htmlFor="description">Description</label>
43
+
<Label htmlFor="description">Description</Label>
40
44
<Textarea
41
45
id="description"
42
46
name="description"
···
55
59
class="hidden"
56
60
/>
57
61
</div>
58
-
<form
59
-
id="delete-form"
60
-
hx-post={`/actions/gallery/delete?uri=${gallery?.uri}`}
61
-
>
62
-
<input type="hidden" name="uri" value={gallery?.uri} />
63
-
</form>
64
62
<div class="flex flex-col gap-2 mt-2">
65
63
<Button
66
64
variant="primary"
···
70
68
>
71
69
{gallery ? "Update gallery" : "Create gallery"}
72
70
</Button>
73
-
{gallery
74
-
? (
75
-
<Button
76
-
variant="destructive"
77
-
form="delete-form"
78
-
type="submit"
79
-
class="w-full"
80
-
>
81
-
Delete gallery
82
-
</Button>
83
-
)
84
-
: null}
85
71
<Button
86
72
variant="secondary"
87
73
type="button"
+84
src/components/GalleryEditPhotosDialog.tsx
+84
src/components/GalleryEditPhotosDialog.tsx
···
1
+
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
+
import { $Typed } from "$lexicon/util.ts";
3
+
import { Button } from "./Button.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
5
+
import { LibaryPhotoSelectDialogButton } from "./LibraryPhotoSelectDialog.tsx";
6
+
import { PhotoSelectButton } from "./PhotoSelectButton.tsx";
7
+
8
+
export function GalleryEditPhotosDialog({
9
+
galleryUri,
10
+
photos,
11
+
}: Readonly<{
12
+
galleryUri: string;
13
+
photos: $Typed<PhotoView>[];
14
+
}>) {
15
+
return (
16
+
<Dialog id="photo-select-dialog">
17
+
<Dialog.Content class="flex flex-col gap-4">
18
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
19
+
<Dialog.Title>Edit photos</Dialog.Title>
20
+
<div class="flex flex-col gap-4">
21
+
<form
22
+
class="w-full flex flex-col gap-4"
23
+
hx-encoding="multipart/form-data"
24
+
_="on change from #file-input call Grain.galleryPhotosDialog.uploadPhotos(me)"
25
+
>
26
+
<input hidden name="page" value="gallery" />
27
+
<input hidden name="galleryUri" value={galleryUri} />
28
+
<Button variant="primary" class="w-full" asChild>
29
+
<label>
30
+
<i class="fa-solid fa-cloud-arrow-up mr-2" />
31
+
Upload photos
32
+
<input
33
+
id="file-input"
34
+
class="hidden"
35
+
type="file"
36
+
name="files"
37
+
multiple
38
+
accept="image/*"
39
+
/>
40
+
</label>
41
+
</Button>
42
+
43
+
<label class="block gap-2">
44
+
<input
45
+
id="parse-exif"
46
+
type="checkbox"
47
+
name="parseExif"
48
+
class="mr-2 accent-sky-600"
49
+
checked
50
+
/>
51
+
Include image metadata (EXIF)
52
+
<button
53
+
type="button"
54
+
hx-get="/dialogs/exif-info"
55
+
hx-target="#layout"
56
+
hx-swap="afterbegin"
57
+
class="cursor-pointer"
58
+
>
59
+
<i class="fa fa-info-circle ml-1" />
60
+
</button>
61
+
</label>
62
+
</form>
63
+
<LibaryPhotoSelectDialogButton galleryUri={galleryUri} />
64
+
</div>
65
+
<div class="flex-1 overflow-y-auto">
66
+
<div id="image-preview" class="grid grid-cols-3 gap-2">
67
+
{photos.length
68
+
? (
69
+
photos.map((photo) => (
70
+
<PhotoSelectButton
71
+
key={photo.cid}
72
+
galleryUri={galleryUri}
73
+
photo={photo}
74
+
/>
75
+
))
76
+
)
77
+
: null}
78
+
</div>
79
+
</div>
80
+
<Dialog.Close variant="secondary" class="w-full">Close</Dialog.Close>
81
+
</Dialog.Content>
82
+
</Dialog>
83
+
);
84
+
}
+1
-1
src/components/GalleryLayout.tsx
+1
-1
src/components/GalleryLayout.tsx
···
1
1
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
2
2
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
3
import { AtUri } from "@atproto/syntax";
4
-
import { Button } from "@bigmoves/bff/components";
5
4
import { ComponentChildren } from "preact";
6
5
import { photoDialogLink } from "../utils.ts";
6
+
import { Button } from "./Button.tsx";
7
7
import { JustifiedSvg } from "./JustifiedSvg.tsx";
8
8
import { MasonrySvg } from "./MasonrySvg.tsx";
9
9
+13
-28
src/components/GalleryPage.tsx
+13
-28
src/components/GalleryPage.tsx
···
3
3
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
4
4
import { AtUri } from "@atproto/syntax";
5
5
import { WithBffMeta } from "@bigmoves/bff";
6
-
import { Button } from "@bigmoves/bff/components";
7
6
import { ModerationDecsion } from "../lib/moderation.ts";
7
+
import { EditGalleryButton } from "./EditGalleryDialog.tsx";
8
8
import { FavoriteButton } from "./FavoriteButton.tsx";
9
9
import { GalleryInfo } from "./GalleryInfo.tsx";
10
10
import { GalleryLayout } from "./GalleryLayout.tsx";
···
27
27
const galleryItems = gallery.items?.filter(isPhotoView) ?? [];
28
28
return (
29
29
<div class="px-4" id="gallery-page">
30
+
<div id="dialog-target" />
30
31
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4 mb-2">
31
32
<GalleryInfo gallery={gallery} />
32
33
{isLoggedIn && isCreator
33
34
? (
34
35
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end">
35
-
<Button
36
-
variant="primary"
37
-
class="self-start w-full sm:w-fit whitespace-nowrap"
38
-
hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`}
39
-
hx-target="#layout"
40
-
hx-swap="afterbegin"
41
-
>
42
-
Edit
43
-
</Button>
44
-
<Button
45
-
hx-get={`/dialogs/photo-select/${new AtUri(gallery.uri).rkey}`}
46
-
hx-target="#layout"
47
-
hx-swap="afterbegin"
48
-
variant="primary"
49
-
class="self-start w-full sm:w-fit whitespace-nowrap"
50
-
>
51
-
Add photos
52
-
</Button>
53
-
<Button
54
-
variant="primary"
55
-
class="self-start w-full sm:w-fit whitespace-nowrap"
56
-
hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/sort`}
57
-
hx-target="#layout"
58
-
hx-swap="afterbegin"
59
-
>
60
-
Sort order
61
-
</Button>
36
+
<EditGalleryButton gallery={gallery} />
62
37
<ShareGalleryButton gallery={gallery} />
63
38
<FavoriteButton
64
39
currentUserDid={currentUserDid}
···
81
56
)
82
57
: null}
83
58
</div>
59
+
{isLoggedIn && isCreator && gallery.items?.length === 0
60
+
? (
61
+
<div
62
+
hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/photos`}
63
+
hx-trigger="load"
64
+
hx-target="#dialog-target"
65
+
hx-swap="innerHTML"
66
+
/>
67
+
)
68
+
: null}
84
69
{
85
70
<ModerationWrapper moderationDecision={modDecision} class="mb-2">
86
71
<GalleryLayout
+1
-1
src/components/GalleryPreviewLink.tsx
+1
-1
src/components/GalleryPreviewLink.tsx
+129
src/components/GallerySelectDialog.tsx
+129
src/components/GallerySelectDialog.tsx
···
1
+
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
2
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { Button } from "./Button.tsx";
5
+
import { Dialog } from "./Dialog.tsx";
6
+
import { Input } from "./Input.tsx";
7
+
8
+
export function GallerySelectDialog(
9
+
{ photoUri, userDid, galleries }: Readonly<
10
+
{
11
+
photoUri?: string;
12
+
userDid: string;
13
+
galleries: GalleryView[];
14
+
}
15
+
>,
16
+
) {
17
+
return (
18
+
<Dialog id="gallery-select-dialog">
19
+
<Dialog.Content class="min-h-[calc(100vh-100px)] overflow-hidden flex flex-col gap-4">
20
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
21
+
<Dialog.Title>
22
+
{photoUri ? "Add to gallery" : "Select gallery"}
23
+
</Dialog.Title>
24
+
25
+
<form>
26
+
<Input
27
+
type="text"
28
+
name="q"
29
+
placeholder="Enter gallery name or select from the list"
30
+
hx-get={`/dialogs/gallery/${userDid}/select`}
31
+
hx-target="#search-results"
32
+
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
33
+
hx-swap="innerHTML"
34
+
autoFocus
35
+
/>
36
+
</form>
37
+
38
+
<div id="search-results" class="flex-1 overflow-y-auto">
39
+
<GallerySelectDialogSearchResults
40
+
photoUri={photoUri}
41
+
galleries={galleries}
42
+
/>
43
+
</div>
44
+
<Dialog.Close variant="secondary">
45
+
Close
46
+
</Dialog.Close>
47
+
</Dialog.Content>
48
+
</Dialog>
49
+
);
50
+
}
51
+
52
+
export function GallerySelectDialogSearchResults(
53
+
{ photoUri, galleries }: {
54
+
photoUri?: string;
55
+
galleries: GalleryView[];
56
+
},
57
+
) {
58
+
return (
59
+
galleries.length > 0
60
+
? (
61
+
<ul class="divide-zinc-200 dark:divide-zinc-800 divide-y">
62
+
{galleries.map((gallery) => (
63
+
<li
64
+
key={gallery.cid}
65
+
class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800"
66
+
>
67
+
{photoUri
68
+
? (
69
+
<button
70
+
type="button"
71
+
hx-put={addToGalleryActionLink(photoUri, gallery.uri)}
72
+
hx-swap="none"
73
+
class="block text-left w-full px-2 py-4"
74
+
>
75
+
{(gallery.record as Gallery).title}
76
+
<div class="text-sm text-zinc-600 dark:text-zinc-500">
77
+
{(gallery.record as Gallery).description}
78
+
</div>
79
+
</button>
80
+
)
81
+
: (
82
+
<a
83
+
href={uploadPageLink(gallery.uri)}
84
+
class="block w-full px-2 py-4"
85
+
>
86
+
{(gallery.record as Gallery).title}
87
+
<div class="text-sm text-zinc-600 dark:text-zinc-500">
88
+
{(gallery.record as Gallery).description}
89
+
</div>
90
+
</a>
91
+
)}
92
+
</li>
93
+
))}
94
+
</ul>
95
+
)
96
+
: <p>No galleries found.</p>
97
+
);
98
+
}
99
+
100
+
function addToGalleryActionLink(photoUri: string, galleryUri: string) {
101
+
const photoRKey = new AtUri(photoUri).rkey;
102
+
const galleryRkey = new AtUri(galleryUri).rkey;
103
+
return `/actions/gallery/${galleryRkey}/add-photo/${photoRKey}?page=upload`;
104
+
}
105
+
106
+
function uploadPageLink(galleryUri: string) {
107
+
const rkey = new AtUri(galleryUri).rkey;
108
+
return `/upload?gallery=${rkey}`;
109
+
}
110
+
111
+
export function GallerySelectDialogButton(
112
+
{ userDid }: Readonly<{ userDid: string }>,
113
+
) {
114
+
return (
115
+
<Button
116
+
type="button"
117
+
variant="secondary"
118
+
class="w-full sm:w-fit"
119
+
hx-get={`/dialogs/gallery/${userDid}/select`}
120
+
hx-trigger="click"
121
+
hx-target="#layout"
122
+
hx-swap="afterbegin"
123
+
_="on click halt"
124
+
>
125
+
<i class="fa fa-filter mr-2" />
126
+
Filter by gallery
127
+
</Button>
128
+
);
129
+
}
+4
-3
src/components/GallerySortDialog.tsx
+4
-3
src/components/GallerySortDialog.tsx
···
1
1
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
2
2
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
3
import { AtUri } from "@atproto/syntax";
4
-
import { Button, Dialog } from "@bigmoves/bff/components";
4
+
import { Button } from "./Button.tsx";
5
+
import { Dialog } from "./Dialog.tsx";
5
6
6
7
export function GallerySortDialog(
7
8
{ gallery }: Readonly<{ gallery: GalleryView }>,
8
9
) {
9
10
return (
10
-
<Dialog class="z-100" id="gallery-sort-dialog">
11
-
<Dialog.Content class="dark:bg-zinc-950 relative">
11
+
<Dialog id="gallery-sort-dialog">
12
+
<Dialog.Content>
12
13
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
13
14
<Dialog.Title>Sort gallery</Dialog.Title>
14
15
<p class="my-2 text-center">Drag photos to rearrange</p>
+13
src/components/Input.tsx
+13
src/components/Input.tsx
···
1
+
import { cn } from "@bigmoves/bff/components";
2
+
import type { JSX } from "preact";
3
+
4
+
export type InputProps = JSX.InputHTMLAttributes<HTMLInputElement>;
5
+
6
+
export function Input(props: InputProps): JSX.Element {
7
+
const { class: classProp, ...rest } = props;
8
+
const className = cn(
9
+
"grain-input",
10
+
classProp,
11
+
);
12
+
return <input class={className} {...rest} />;
13
+
}
+13
src/components/Label.tsx
+13
src/components/Label.tsx
···
1
+
import { cn } from "@bigmoves/bff/components";
2
+
import type { JSX } from "preact";
3
+
4
+
export type LabelProps = JSX.LabelHTMLAttributes<HTMLLabelElement>;
5
+
6
+
export function Label(props: LabelProps): JSX.Element {
7
+
const { class: classProp, ...rest } = props;
8
+
const className = cn(
9
+
"grain-label",
10
+
classProp,
11
+
);
12
+
return <label class={className} {...rest} />;
13
+
}
+3
-3
src/components/LabelDefinitionDialog.tsx
+3
-3
src/components/LabelDefinitionDialog.tsx
···
1
1
import { LabelValueDefinition } from "$lexicon/types/com/atproto/label/defs.ts";
2
-
import { Dialog } from "@bigmoves/bff/components";
3
2
import { profileLink } from "../utils.ts";
3
+
import { Dialog } from "./Dialog.tsx";
4
4
5
5
export function LabelDefinitionDialog({
6
6
labelValueDefinition,
···
13
13
(locale) => locale.lang === "en",
14
14
);
15
15
return (
16
-
<Dialog id="mod-decision-dialog" class="z-100">
17
-
<Dialog.Content class="dark:bg-zinc-950 relative gap-2">
16
+
<Dialog id="mod-decision-dialog">
17
+
<Dialog.Content class="gap-2">
18
18
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
19
19
<Dialog.Title>{enLocale?.name}</Dialog.Title>
20
20
<p>{enLocale?.description}</p>
+8
-8
src/components/Layout.tsx
+8
-8
src/components/Layout.tsx
···
1
1
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
2
2
import { Un$Typed } from "$lexicon/util.ts";
3
-
import { Button, cn } from "@bigmoves/bff/components";
3
+
import { cn } from "@bigmoves/bff/components";
4
4
import type { FunctionalComponent, JSX } from "preact";
5
5
import { ActorAvatar } from "./ActorAvatar.tsx";
6
+
import { Button } from "./Button.tsx";
6
7
7
8
type LayoutProps = JSX.HTMLAttributes<HTMLDivElement> & {
8
9
children: preact.ComponentChildren;
···
77
78
<div class="flex space-x-2">
78
79
{profile
79
80
? (
80
-
<div class="flex items-center ts:space-x-1 sm:space-x-2">
81
+
<div class="flex items-center space-x-1 sm:space-x-2">
81
82
<form hx-post="/logout" hx-swap="none" class="inline">
82
83
<Button type="submit" variant="secondary">Sign out</Button>
83
84
</form>
84
85
<Button
85
86
asChild
86
-
variant="secondary"
87
-
class="relative pl-2"
87
+
variant="ghost"
88
88
>
89
89
<a href="/explore">
90
90
<i class="fas fa-search text-zinc-950 dark:text-zinc-50" />
···
92
92
</Button>
93
93
<Button
94
94
asChild
95
-
variant="secondary"
96
-
class="relative pl-2"
95
+
variant="ghost"
96
+
class="relative"
97
97
>
98
98
<a href="/notifications">
99
99
<i class="fas fa-bell text-zinc-950 dark:text-zinc-50" />
···
110
110
</div>
111
111
)
112
112
: (
113
-
<div class="flex items-center space-x-4">
113
+
<div class="flex items-center space-x-2">
114
114
<Button
115
115
variant="secondary"
116
116
hx-get={`/dialogs/create-account`}
···
120
120
>
121
121
Create account
122
122
</Button>
123
-
<Button variant="secondary" asChild>
123
+
<Button variant="primary" asChild>
124
124
<a href="/login">
125
125
Sign in
126
126
</a>
+123
src/components/LibraryPhotoSelectDialog.tsx
+123
src/components/LibraryPhotoSelectDialog.tsx
···
1
+
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
+
import { $Typed } from "$lexicon/util.ts";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { Button } from "./Button.tsx";
5
+
import { Dialog } from "./Dialog.tsx";
6
+
7
+
export function LibraryPhotoSelectDialog({
8
+
galleryUri,
9
+
photos,
10
+
}: Readonly<{
11
+
galleryUri: string;
12
+
photos: $Typed<PhotoView>[];
13
+
}>) {
14
+
const rkey = new AtUri(galleryUri).rkey;
15
+
return (
16
+
<Dialog id="photo-select-dialog" class="z-101">
17
+
<Dialog.Content class="flex flex-col gap-4">
18
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
19
+
<Dialog.Title>My library</Dialog.Title>
20
+
21
+
<form
22
+
id="photo-select-form"
23
+
hx-put={`/actions/gallery/${rkey}/add-photos`}
24
+
hx-target="#dialog-target"
25
+
hx-swap="innerHTML"
26
+
class="flex-1 overflow-y-auto"
27
+
>
28
+
{photos.length
29
+
? (
30
+
<div class="grid grid-cols-3 gap-2">
31
+
{photos.map((photo) => (
32
+
<PhotoItem
33
+
key={photo.cid}
34
+
photo={photo}
35
+
/>
36
+
))}
37
+
</div>
38
+
)
39
+
: (
40
+
<div class="flex justify-center items-center my-30 h-full">
41
+
<p>No photos yet.</p>
42
+
</div>
43
+
)}
44
+
</form>
45
+
46
+
<div
47
+
id="photo-select-overlay"
48
+
class="w-full bg-white dark:bg-zinc-900 flex justify-between items-center z-102"
49
+
_="on load set my.count to 0"
50
+
>
51
+
<span id="selected-count">0 selected</span>
52
+
<Button
53
+
type="submit"
54
+
form="photo-select-form"
55
+
variant="primary"
56
+
>
57
+
Add to gallery
58
+
</Button>
59
+
</div>
60
+
61
+
<Dialog.Close variant="secondary" class="w-full">Close</Dialog.Close>
62
+
</Dialog.Content>
63
+
</Dialog>
64
+
);
65
+
}
66
+
67
+
export function LibaryPhotoSelectDialogButton({ galleryUri }: Readonly<{
68
+
galleryUri: string;
69
+
}>) {
70
+
const rkey = new AtUri(galleryUri).rkey;
71
+
return (
72
+
<Button
73
+
type="button"
74
+
variant="secondary"
75
+
hx-get={`/dialogs/gallery/${rkey}/library`}
76
+
hx-trigger="click"
77
+
hx-target="#dialog-target"
78
+
hx-swap="innerHTML"
79
+
>
80
+
<i class="fa-solid fa-plus mr-2" />
81
+
Add from library
82
+
</Button>
83
+
);
84
+
}
85
+
86
+
export function PhotoItem({
87
+
photo,
88
+
}: Readonly<{
89
+
photo: PhotoView;
90
+
}>) {
91
+
return (
92
+
<button
93
+
type="button"
94
+
class="group relative aspect-square cursor-pointer"
95
+
_="
96
+
on click
97
+
set checkbox to me.querySelector('input[type=checkbox]')
98
+
set checkbox.checked to not checkbox.checked
99
+
trigger change on checkbox
100
+
"
101
+
>
102
+
<input
103
+
type="checkbox"
104
+
name="photoUri"
105
+
value={photo.uri}
106
+
class="peer absolute top-2 left-2 z-30 w-5 h-5 accent-sky-600"
107
+
_="
108
+
on change
109
+
set checkedCount to my.closest('form') or document
110
+
then set checkedInputs to checkedCount.querySelectorAll('input[type=checkbox]:checked')
111
+
then set count to checkedInputs.length
112
+
then set #selected-count's innerText to `${count} selected`"
113
+
/>
114
+
115
+
<img
116
+
src={photo.fullsize}
117
+
alt={photo.alt}
118
+
loading="lazy"
119
+
class="w-full h-full object-cover transition-opacity duration-200 peer-checked:opacity-50"
120
+
/>
121
+
</button>
122
+
);
123
+
}
+75
src/components/Login.tsx
+75
src/components/Login.tsx
···
1
+
import { cn } from "@bigmoves/bff/components";
2
+
import type { JSX } from "preact";
3
+
import { Button } from "./Button.tsx";
4
+
import { Input } from "./Input.tsx";
5
+
6
+
export type LoginProps =
7
+
& JSX.HTMLAttributes<HTMLFormElement>
8
+
& Readonly<{
9
+
inputPlaceholder?: string;
10
+
submitText?: string;
11
+
infoText?: string;
12
+
error?: string;
13
+
errorClass?: string;
14
+
infoClass?: string;
15
+
}>;
16
+
17
+
export function Login(
18
+
{
19
+
inputPlaceholder = "Handle (e.g., user.bsky.social)",
20
+
submitText = "Login with Bluesky",
21
+
infoText = "",
22
+
error,
23
+
errorClass,
24
+
infoClass,
25
+
...rest
26
+
}: LoginProps,
27
+
): JSX.Element {
28
+
return (
29
+
<form
30
+
id="login-form"
31
+
hx-post="/oauth/login"
32
+
hx-target="#login-form"
33
+
hx-swap="outerHTML"
34
+
{...rest}
35
+
class={cn(
36
+
"tw:mx-4 tw:sm:mx-0 tw:w-full tw:sm:max-w-[300px] tw:space-y-2",
37
+
rest.class,
38
+
)}
39
+
>
40
+
<div>
41
+
<label htmlFor="handle" class="tw:sr-only">
42
+
Handle
43
+
</label>
44
+
<Input
45
+
id="handle"
46
+
class="bg-white text-zinc-900"
47
+
placeholder={inputPlaceholder}
48
+
name="handle"
49
+
/>
50
+
</div>
51
+
<Button
52
+
variant="primary"
53
+
id="submit"
54
+
type="submit"
55
+
class="tw:w-full"
56
+
>
57
+
{submitText}
58
+
</Button>
59
+
{infoText && (
60
+
<div class={cn("tw:text-sm tw:text", infoClass)}>
61
+
{infoText}
62
+
</div>
63
+
)}
64
+
<div className="tw:h-4">
65
+
{error
66
+
? (
67
+
<div className={cn("tw:text-sm tw:font-mono", errorClass)}>
68
+
{error}
69
+
</div>
70
+
)
71
+
: null}
72
+
</div>
73
+
</form>
74
+
);
75
+
}
+1
-1
src/components/LoginPage.tsx
+1
-1
src/components/LoginPage.tsx
+10
-5
src/components/PhotoAltDialog.tsx
+10
-5
src/components/PhotoAltDialog.tsx
···
1
1
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
2
import { AtUri } from "@atproto/syntax";
3
-
import { Button, Dialog, Textarea } from "@bigmoves/bff/components";
3
+
import { Button } from "./Button.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
5
+
import { Label } from "./Label.tsx";
6
+
import { Textarea } from "./Textarea.tsx";
4
7
5
8
export function PhotoAltDialog({
6
9
photo,
···
8
11
photo: PhotoView;
9
12
}>) {
10
13
return (
11
-
<Dialog id="photo-alt-dialog" class="z-100">
12
-
<Dialog.Content class="dark:bg-zinc-950 relative">
14
+
<Dialog id="photo-alt-dialog">
15
+
<Dialog.Content>
13
16
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
14
17
<Dialog.Title>Add alt text</Dialog.Title>
15
18
<div class="aspect-square relative">
···
24
27
_="on htmx:afterOnLoad trigger closeDialog"
25
28
>
26
29
<div class="my-2">
27
-
<label htmlFor="alt">Descriptive alt text</label>
30
+
<Label htmlFor="alt">Descriptive alt text</Label>
28
31
<Textarea
29
32
id="alt"
30
33
name="alt"
···
39
42
<Button type="submit" variant="primary" class="w-full">
40
43
Save
41
44
</Button>
42
-
<Dialog.Close class="w-full">Cancel</Dialog.Close>
45
+
<Dialog.Close variant="secondary" class="w-full">
46
+
Close
47
+
</Dialog.Close>
43
48
</div>
44
49
</form>
45
50
</Dialog.Content>
+3
-2
src/components/PhotoDialog.tsx
+3
-2
src/components/PhotoDialog.tsx
···
1
1
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
2
2
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
3
import { AtUri } from "@atproto/syntax";
4
-
import { cn, Dialog } from "@bigmoves/bff/components";
4
+
import { cn } from "@bigmoves/bff/components";
5
5
import { photoDialogLink } from "../utils.ts";
6
+
import { Dialog } from "./Dialog.tsx";
6
7
7
8
export function PhotoDialog({
8
9
gallery,
···
16
17
prevImage?: PhotoView;
17
18
}>) {
18
19
return (
19
-
<Dialog id="photo-dialog" class="bg-zinc-950 z-100">
20
+
<Dialog id="photo-dialog" class="bg-zinc-950">
20
21
<Dialog.X />
21
22
{nextImage
22
23
? (
+1
-1
src/components/PhotoExifButton.tsx
+1
-1
src/components/PhotoExifButton.tsx
···
3
3
export function PhotoExifButton({ photoUri }: Readonly<{ photoUri: string }>) {
4
4
return (
5
5
<div
6
-
class="bg-zinc-950 dark:bg-zinc-950 py-[1px] px-[3px] absolute bottom-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10"
6
+
class="bg-zinc-950/50 dark:bg-zinc-950/50 py-[1px] px-[3px] absolute bottom-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10"
7
7
hx-get={`/dialogs/photo/${new AtUri(photoUri).rkey}/exif`}
8
8
hx-trigger="click"
9
9
hx-target="#layout"
+6
-3
src/components/PhotoExifDialog.tsx
+6
-3
src/components/PhotoExifDialog.tsx
···
1
1
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
-
import { Dialog } from "@bigmoves/bff/components";
3
2
import { getOrderedExifData } from "../lib/photo.ts";
3
+
import { Dialog } from "./Dialog.tsx";
4
4
5
5
export function PhotoExifDialog({
6
6
photo,
···
8
8
photo: PhotoView;
9
9
}>) {
10
10
return (
11
-
<Dialog id="photo-alt-dialog" class="z-100">
12
-
<Dialog.Content class="dark:bg-zinc-950 relative">
11
+
<Dialog id="photo-alt-dialog">
12
+
<Dialog.Content>
13
13
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
14
14
<Dialog.Title>Camera Settings</Dialog.Title>
15
15
<div class="aspect-square relative">
···
31
31
))}
32
32
</div>
33
33
)}
34
+
<Dialog.Close variant="secondary" class="w-full mt-4">
35
+
Close
36
+
</Dialog.Close>
34
37
</Dialog.Content>
35
38
</Dialog>
36
39
);
+22
-7
src/components/PhotoPreview.tsx
+22
-7
src/components/PhotoPreview.tsx
···
1
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
1
2
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
3
import { Un$Typed } from "$lexicon/util.ts";
3
4
import { AtUri } from "@atproto/syntax";
4
5
import { AltTextButton } from "./AltTextButton.tsx";
5
6
import { PhotoExifButton } from "./PhotoExifButton.tsx";
7
+
import { RemovePhotoDialogButton } from "./RemovePhotoDialog.tsx";
6
8
7
9
export function PhotoPreview({
8
10
photo,
11
+
selectedGallery,
9
12
}: Readonly<{
10
13
photo: Un$Typed<PhotoView>;
14
+
selectedGallery?: GalleryView;
11
15
}>) {
12
-
const rkey = new AtUri(photo.uri).rkey;
16
+
const atUri = new AtUri(photo.uri);
17
+
const did = atUri.hostname;
18
+
const rkey = atUri.rkey;
13
19
return (
14
20
<div
15
21
class="relative aspect-square bg-zinc-200 dark:bg-zinc-900"
16
-
id={rkey}
22
+
id={`photo-${rkey}`}
17
23
>
18
24
{photo.uri ? <AltTextButton photoUri={photo.uri} /> : null}
19
25
{photo.exif ? <PhotoExifButton photoUri={photo.uri} /> : null}
20
26
{photo.uri
21
27
? (
28
+
<RemovePhotoDialogButton
29
+
selectedGallery={selectedGallery}
30
+
photoUri={photo.uri}
31
+
/>
32
+
)
33
+
: null}
34
+
{photo.uri
35
+
? (
22
36
<button
23
37
type="button"
24
-
id={`delete-photo-${rkey}`}
25
-
hx-delete={`/actions/photo/${rkey}`}
26
-
class="bg-zinc-950 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center"
27
-
_="on htmx:afterOnLoad remove me.parentNode"
38
+
hx-get={`/dialogs/gallery/${did}/select?photoUri=${photo.uri}`}
39
+
hx-trigger="click"
40
+
hx-target="#layout"
41
+
hx-swap="afterbegin"
42
+
class="bg-zinc-950/50 z-10 absolute bottom-2 right-2 cursor-pointer size-4 flex items-center justify-center"
28
43
>
29
-
<i class="fas fa-close text-white"></i>
44
+
<i class="fas fa-plus text-white"></i>
30
45
</button>
31
46
)
32
47
: null}
+13
-20
src/components/PhotoSelectButton.tsx
+13
-20
src/components/PhotoSelectButton.tsx
···
3
3
4
4
export function PhotoSelectButton({
5
5
galleryUri,
6
-
itemUris,
7
6
photo,
8
7
}: Readonly<{
9
8
galleryUri: string;
10
-
itemUris: string[];
11
9
photo: PhotoView;
12
10
}>) {
11
+
const galleryRkey = new AtUri(galleryUri).rkey;
12
+
const photoRkey = new AtUri(photo.uri).rkey;
13
13
return (
14
14
<button
15
-
hx-put={`/actions/gallery/${new AtUri(galleryUri).rkey}/${
16
-
itemUris.includes(photo.uri) ? "remove-photo" : "add-photo"
17
-
}/${new AtUri(photo.uri).rkey}`}
18
-
hx-swap="outerHTML"
15
+
hx-put={`/actions/gallery/${galleryRkey}/remove-photo/${photoRkey}?selectedGallery=${
16
+
galleryUri ?? ""
17
+
}`}
18
+
hx-swap="none"
19
+
hx-confirm="Are you sure you want to remove this photo from the gallery?"
19
20
type="button"
20
-
data-added={itemUris.includes(photo.uri) ? "true" : "false"}
21
-
class="group cursor-pointer relative aspect-square data-[added=true]:ring-2 ring-sky-500 disabled:opacity-50"
22
-
_={`on htmx:beforeRequest add @disabled to me
23
-
then on htmx:afterOnLoad
24
-
remove @disabled from me
25
-
if @data-added == 'true'
26
-
set @data-added to 'false'
27
-
remove #photo-${new AtUri(photo.uri).rkey}
28
-
else
29
-
set @data-added to 'true'
30
-
end`}
21
+
class="group cursor-pointer aspect-square relative"
22
+
_={`on htmx:afterOnLoad remove me`}
31
23
>
32
-
<div class="hidden group-data-[added=true]:block absolute top-2 right-2 z-30">
33
-
<i class="fa-check fa-solid text-sky-500 z-10" />
24
+
<div class="absolute top-2 right-2 z-30 size-4 bg-zinc-950/50 flex items-center justify-center">
25
+
<i class="fa-close fa-solid text-white z-10" />
34
26
</div>
35
27
<img
36
28
src={photo.fullsize}
37
29
alt={photo.alt}
38
-
class="absolute inset-0 w-full h-full object-contain"
30
+
class="w-full h-full object-cover"
31
+
loading="lazy"
39
32
/>
40
33
</button>
41
34
);
-61
src/components/PhotoSelectDialog.tsx
-61
src/components/PhotoSelectDialog.tsx
···
1
-
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
-
import { AtUri } from "@atproto/syntax";
3
-
import { Dialog } from "@bigmoves/bff/components";
4
-
import { PhotoSelectButton } from "./PhotoSelectButton.tsx";
5
-
6
-
export function PhotoSelectDialog({
7
-
galleryUri,
8
-
itemUris,
9
-
photos,
10
-
}: Readonly<{
11
-
galleryUri: string;
12
-
itemUris: string[];
13
-
photos: PhotoView[];
14
-
}>) {
15
-
return (
16
-
<Dialog id="photo-select-dialog" class="z-100">
17
-
<Dialog.Content class="w-full max-w-5xl dark:bg-zinc-950 sm:min-h-screen flex flex-col relative">
18
-
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
19
-
<Dialog.Title>Add photos</Dialog.Title>
20
-
{photos.length
21
-
? (
22
-
<p class="my-2 text-center">
23
-
Choose photos to add/remove from your gallery. Click close when
24
-
done.
25
-
</p>
26
-
)
27
-
: null}
28
-
{photos.length
29
-
? (
30
-
<div class="grid grid-cols-3 sm:grid-cols-5 gap-4 my-4 flex-1">
31
-
{photos.map((photo) => (
32
-
<PhotoSelectButton
33
-
key={photo.cid}
34
-
galleryUri={galleryUri}
35
-
itemUris={itemUris}
36
-
photo={photo}
37
-
/>
38
-
))}
39
-
</div>
40
-
)
41
-
: (
42
-
<div class="flex-1 flex justify-center items-center my-30">
43
-
<p>
44
-
No photos yet.{" "}
45
-
<a
46
-
href={`/upload?returnTo=${new AtUri(galleryUri).rkey}`}
47
-
class="hover:underline font-semibold text-sky-500"
48
-
>
49
-
Upload
50
-
</a>{" "}
51
-
photos and return to add.
52
-
</p>
53
-
</div>
54
-
)}
55
-
<div class="w-full flex flex-col gap-2 mt-2">
56
-
<Dialog.Close class="w-full">Close</Dialog.Close>
57
-
</div>
58
-
</Dialog.Content>
59
-
</Dialog>
60
-
);
61
-
}
+29
-22
src/components/ProfileDialog.tsx
+29
-22
src/components/ProfileDialog.tsx
···
1
1
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
2
-
import { Button, Dialog, Input, Textarea } from "@bigmoves/bff/components";
3
2
import { AvatarInput } from "./AvatarInput.tsx";
3
+
import { Button } from "./Button.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
5
+
import { Input } from "./Input.tsx";
6
+
import { Label } from "./Label.tsx";
7
+
import { Textarea } from "./Textarea.tsx";
4
8
5
9
export function ProfileDialog({
6
10
profile,
···
8
12
profile: ProfileView;
9
13
}>) {
10
14
return (
11
-
<Dialog class="z-100">
12
-
<Dialog.Content class="dark:bg-zinc-950 relative">
15
+
<Dialog>
16
+
<Dialog.Content>
13
17
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
14
18
<Dialog.Title>Edit my profile</Dialog.Title>
15
19
<form
16
20
id="profile-form"
17
21
hx-encoding="multipart/form-data"
22
+
autocomplete="off"
18
23
_="on submit
19
24
halt the event
20
25
put 'Updating...' into #submit-button.innerText
···
31
36
>
32
37
<AvatarInput profile={profile} />
33
38
<div class="mb-4 relative">
34
-
<label htmlFor="displayName">Display Name</label>
39
+
<Label htmlFor="displayName">Display Name</Label>
35
40
<Input
36
41
type="text"
37
42
id="displayName"
38
43
name="displayName"
39
44
placeholder="e.g. Ansel Lastname"
40
-
class="dark:bg-zinc-800 dark:text-white"
41
45
value={profile.displayName}
42
46
autoFocus
47
+
autocomplete="off"
43
48
/>
44
49
</div>
45
50
<div class="mb-4 relative">
46
-
<label htmlFor="description">Description</label>
51
+
<Label htmlFor="description">Description</Label>
47
52
<Textarea
48
53
id="description"
49
54
name="description"
···
54
59
{profile.description}
55
60
</Textarea>
56
61
</div>
57
-
<Button
58
-
type="submit"
59
-
id="submit-button"
60
-
variant="primary"
61
-
class="w-full"
62
-
>
63
-
Update
64
-
</Button>
65
-
<Button
66
-
variant="secondary"
67
-
type="button"
68
-
class="w-full"
69
-
_={Dialog._closeOnClick}
70
-
>
71
-
Cancel
72
-
</Button>
62
+
<div class="flex flex-col gap-2">
63
+
<Button
64
+
type="submit"
65
+
id="submit-button"
66
+
variant="primary"
67
+
class="w-full"
68
+
>
69
+
Update
70
+
</Button>
71
+
<Button
72
+
variant="secondary"
73
+
type="button"
74
+
class="w-full"
75
+
_={Dialog._closeOnClick}
76
+
>
77
+
Cancel
78
+
</Button>
79
+
</div>
73
80
</form>
74
81
</Dialog.Content>
75
82
</Dialog>
+24
-33
src/components/ProfilePage.tsx
+24
-33
src/components/ProfilePage.tsx
···
6
6
import { Un$Typed } from "$lexicon/util.ts";
7
7
import { AtUri } from "@atproto/syntax";
8
8
import { LabelerPolicies } from "@bigmoves/bff";
9
-
import { Button, cn } from "@bigmoves/bff/components";
10
9
import { getGalleryCameras } from "../lib/gallery.ts";
11
10
import {
12
11
atprotoLabelValueDefinitions,
···
22
21
} from "../utils.ts";
23
22
import { ActorAvatar } from "./ActorAvatar.tsx";
24
23
import { AvatarButton } from "./AvatarButton.tsx";
24
+
import { Button } from "./Button.tsx";
25
25
import { CameraBadges } from "./CameraBadges.tsx";
26
26
import { FollowButton } from "./FollowButton.tsx";
27
27
import { LabelDefinitionButton } from "./LabelDefinitionButton.tsx";
···
125
125
? (
126
126
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end">
127
127
<Button
128
-
variant="primary"
128
+
variant="secondary"
129
129
class="w-full sm:w-fit whitespace-nowrap"
130
130
asChild
131
131
>
132
132
<a href="/upload">
133
133
<i class="fa-solid fa-upload mr-2" />
134
-
Upload
134
+
Photo library
135
135
</a>
136
136
</Button>
137
137
<Button
138
-
variant="primary"
138
+
variant="secondary"
139
139
type="button"
140
140
hx-get="/dialogs/profile"
141
141
hx-target="#layout"
142
142
hx-swap="afterbegin"
143
143
class="w-full sm:w-fit whitespace-nowrap"
144
144
>
145
-
Edit Profile
145
+
Edit profile
146
146
</Button>
147
147
<Button
148
148
variant="primary"
···
152
152
hx-target="#layout"
153
153
hx-swap="afterbegin"
154
154
>
155
-
Create Gallery
155
+
Create gallery
156
156
</Button>
157
157
</div>
158
158
)
···
165
165
>
166
166
{isLabeler
167
167
? (
168
-
<button
169
-
type="button"
168
+
<Button
169
+
variant="tab"
170
170
name="tab"
171
+
class="flex-1"
171
172
value="favs"
172
173
hx-get={profileLink(profile.handle)}
173
174
hx-target="#profile-page"
174
175
hx-swap="outerHTML"
175
-
class={cn(
176
-
"flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold",
177
-
selectedTab === "labels" && "bg-zinc-100 dark:bg-zinc-800",
178
-
)}
179
176
role="tab"
180
177
aria-selected={selectedTab === "labels"}
181
178
aria-controls="tab-content"
182
179
>
183
180
Labels
184
-
</button>
181
+
</Button>
185
182
)
186
183
: (
187
-
<button
188
-
type="button"
184
+
<Button
185
+
variant="tab"
189
186
name="tab"
187
+
class="flex-1"
190
188
value="galleries"
191
189
hx-get={profileLink(profile.handle)}
192
190
hx-target="#profile-page"
193
191
hx-swap="outerHTML"
194
-
class={cn(
195
-
"flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold",
196
-
selectedTab === "galleries" && "bg-zinc-100 dark:bg-zinc-800",
197
-
)}
198
192
role="tab"
199
193
aria-selected={selectedTab === "galleries"}
200
194
aria-controls="tab-content"
201
195
>
202
196
Galleries
203
-
</button>
197
+
</Button>
204
198
)}
205
199
206
200
{isCreator && (
207
-
<button
208
-
type="button"
201
+
<Button
202
+
variant="tab"
209
203
name="tab"
204
+
class="flex-1"
210
205
value="favs"
211
206
hx-get={profileLink(profile.handle)}
212
207
hx-target="#profile-page"
213
208
hx-swap="outerHTML"
214
-
class={cn(
215
-
"flex-1 min-w-[120px] py-2 px-4 cursor-pointer font-semibold",
216
-
selectedTab === "favs" && "bg-zinc-100 dark:bg-zinc-800",
217
-
)}
218
209
role="tab"
219
210
aria-selected={selectedTab === "favs"}
220
211
aria-controls="tab-content"
221
212
>
222
213
Favs
223
-
</button>
214
+
</Button>
224
215
)}
225
216
</div>
226
217
{selectedTab === "labels" && labelerDefinitions
···
228
219
: null}
229
220
{selectedTab === "galleries"
230
221
? (
231
-
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4">
222
+
<div class="grid grid-cols-3 gap-1 mb-4">
232
223
{galleries?.length
233
224
? (
234
225
galleries.map((gallery) => (
···
245
236
: null}
246
237
{selectedTab === "favs"
247
238
? (
248
-
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4">
239
+
<div class="grid grid-cols-3 gap-1 mb-4">
249
240
{galleryFavs?.length
250
241
? (
251
242
galleryFavs.map((gallery) => (
···
321
312
gallery.creator.handle,
322
313
new AtUri(gallery.uri).rkey,
323
314
)}
324
-
class="cursor-pointer relative aspect-square"
315
+
class="cursor-pointer relative aspect-3/4"
325
316
>
326
317
{modDecision && !modDecision.isMe
327
318
? (
···
345
336
/>
346
337
)
347
338
: <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" />}
348
-
<div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 flex items-center gap-2">
339
+
<div class="absolute sm:flex hidden bottom-0 left-0 bg-black/80 text-white p-2 items-center gap-2">
349
340
{(gallery.record as Gallery).title}
350
341
</div>
351
342
</a>
···
365
356
gallery.creator.handle,
366
357
new AtUri(gallery.uri).rkey,
367
358
)}
368
-
class="cursor-pointer relative aspect-square"
359
+
class="cursor-pointer relative aspect-3/4"
369
360
>
370
361
{gallery.items?.length
371
362
? (
···
376
367
/>
377
368
)
378
369
: <div class="w-full h-full bg-zinc-200 dark:bg-zinc-900" />}
379
-
<div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 flex items-center gap-2">
370
+
<div class="absolute bottom-0 left-0 bg-black/80 text-white p-2 hidden sm:flex items-center gap-2">
380
371
<ActorAvatar profile={gallery.creator} size={20} />{" "}
381
372
{(gallery.record as Gallery).title}
382
373
</div>
+93
src/components/RemovePhotoDialog.tsx
+93
src/components/RemovePhotoDialog.tsx
···
1
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
2
+
import { AtUri } from "@atproto/syntax";
3
+
import { Button } from "./Button.tsx";
4
+
import { Dialog } from "./Dialog.tsx";
5
+
6
+
export function RemovePhotoDialog(
7
+
{ photoUri, galleries, selectedGallery }: Readonly<
8
+
{
9
+
photoUri: string;
10
+
galleries: GalleryView[];
11
+
selectedGallery?: GalleryView;
12
+
}
13
+
>,
14
+
) {
15
+
const rkey = new AtUri(photoUri).rkey;
16
+
return (
17
+
<Dialog>
18
+
<Dialog.Content class="gap-4">
19
+
<Dialog.Title>Remove photo</Dialog.Title>
20
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
21
+
22
+
{galleries.length > 0
23
+
? (
24
+
<div id="photo-galleries" class="flex flex-col gap-2">
25
+
<h2>
26
+
This photo appears in the following galleries. Select to remove.
27
+
</h2>
28
+
<ul class="divide-y divide-zinc-200 dark:divide-zinc-800 border-t border-b border-zinc-200 dark:border-zinc-800">
29
+
{galleries.map((gallery) => (
30
+
<li
31
+
key={gallery.uri}
32
+
class="w-full hover:bg-zinc-200 dark:hover:bg-zinc-800"
33
+
>
34
+
<button
35
+
type="button"
36
+
hx-put={`/actions/gallery/${
37
+
new AtUri(gallery.uri).rkey
38
+
}/remove-photo/${rkey}?selectedGallery=${
39
+
selectedGallery?.uri ?? ""
40
+
}`}
41
+
class="flex justify-between items-center text-left w-full px-2 py-4"
42
+
_="on htmx:afterRequest
43
+
if me.closest('ul').children.length is 1
44
+
then remove #photo-galleries
45
+
else remove me.closest('li')"
46
+
>
47
+
{gallery.record.title || "Untitled Gallery"}
48
+
<i class="fa fa-close" />
49
+
</button>
50
+
</li>
51
+
))}
52
+
</ul>
53
+
</div>
54
+
)
55
+
: null}
56
+
57
+
<Button
58
+
variant="destructive"
59
+
hx-delete={`/actions/photo/${rkey}?selectedGallery=${
60
+
selectedGallery?.uri ?? ""
61
+
}`}
62
+
hx-swap="none"
63
+
hx-confirm="Are you sure you want to delete this photo? This action cannot be undone."
64
+
>
65
+
Delete photo
66
+
</Button>
67
+
</Dialog.Content>
68
+
</Dialog>
69
+
);
70
+
}
71
+
72
+
export function RemovePhotoDialogButton(
73
+
{ selectedGallery, photoUri }: Readonly<
74
+
{ selectedGallery?: GalleryView; photoUri: string }
75
+
>,
76
+
) {
77
+
const rkey = new AtUri(photoUri).rkey;
78
+
return (
79
+
<button
80
+
type="button"
81
+
class="bg-zinc-950/50 z-10 absolute top-2 right-2 cursor-pointer size-4 flex items-center justify-center"
82
+
hx-get={`/dialogs/photo/${rkey}/remove?selectedGallery=${
83
+
selectedGallery?.uri ?? ""
84
+
}`}
85
+
hx-trigger="click"
86
+
hx-target="#layout"
87
+
hx-swap="afterbegin"
88
+
_="on click halt"
89
+
>
90
+
<i class="fas fa-close text-white"></i>
91
+
</button>
92
+
);
93
+
}
+13
src/components/Textarea.tsx
+13
src/components/Textarea.tsx
···
1
+
import { cn } from "@bigmoves/bff/components";
2
+
import type { JSX } from "preact";
3
+
4
+
export type TextareaProps = JSX.TextareaHTMLAttributes<HTMLTextAreaElement>;
5
+
6
+
export function Textarea(props: TextareaProps): JSX.Element {
7
+
const { class: classProp, ...rest } = props;
8
+
const className = cn(
9
+
"grain-input",
10
+
classProp,
11
+
);
12
+
return <textarea class={className} {...rest} />;
13
+
}
+9
-17
src/components/Timeline.tsx
+9
-17
src/components/Timeline.tsx
···
1
-
import { cn } from "@bigmoves/bff/components";
2
1
import { type TimelineItem } from "../lib/timeline.ts";
2
+
import { Button } from "./Button.tsx";
3
3
import { Header } from "./Header.tsx";
4
4
import { TimelineItem as Item } from "./TimelineItem.tsx";
5
5
···
21
21
<>
22
22
<div class="my-4 pb-4 border-b border-zinc-200 dark:border-zinc-800">
23
23
<div class="flex sm:w-fit">
24
-
<button
25
-
type="button"
24
+
<Button
25
+
variant="tab"
26
+
class="flex-1"
26
27
hx-get={`/?graph=${selectedGraph}`}
27
28
hx-target="#timeline-page"
28
29
hx-swap="outerHTML"
29
-
class={cn(
30
-
"flex-1 py-2 sm:min-w-[120px] px-4 cursor-pointer font-semibold",
31
-
!selectedTab &&
32
-
"bg-zinc-100 dark:bg-zinc-800 font-semibold",
33
-
)}
34
30
role="tab"
35
31
aria-selected={!selectedTab}
36
32
aria-controls="tab-content"
37
33
>
38
34
Timeline
39
-
</button>
40
-
<button
41
-
type="button"
35
+
</Button>
36
+
<Button
37
+
variant="tab"
38
+
class="flex-1"
42
39
hx-get={`/?tab=following&graph=${selectedGraph}`}
43
40
hx-target="#timeline-page"
44
41
hx-swap="outerHTML"
45
-
class={cn(
46
-
"flex-1 py-2 sm:min-w-[120px] px-4 cursor-pointer font-semibold",
47
-
selectedTab === "following" &&
48
-
"bg-zinc-100 dark:bg-zinc-800 font-semibold",
49
-
)}
50
42
role="tab"
51
43
aria-selected={selectedTab === "following"}
52
44
aria-controls="tab-content"
53
45
_="on click js document.title = 'Following — Grain'; end"
54
46
>
55
47
Following
56
-
</button>
48
+
</Button>
57
49
</div>
58
50
</div>
59
51
<div id="tab-content" role="tabpanel">
+88
-19
src/components/UploadPage.tsx
+88
-19
src/components/UploadPage.tsx
···
1
+
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
2
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
1
3
import { PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
2
-
import { Button } from "@bigmoves/bff/components";
4
+
import { Un$Typed } from "$lexicon/util.ts";
3
5
import { profileLink } from "../utils.ts";
4
6
import { Breadcrumb } from "./Breadcrumb.tsx";
7
+
import { Button } from "./Button.tsx";
8
+
import { GallerySelectDialogButton } from "./GallerySelectDialog.tsx";
5
9
import { PhotoPreview } from "./PhotoPreview.tsx";
6
10
7
11
export function UploadPage({
8
-
handle,
12
+
userDid,
13
+
userHandle,
9
14
photos,
10
15
returnTo,
11
-
}: Readonly<{ handle: string; photos: PhotoView[]; returnTo?: string }>) {
16
+
selectedGallery,
17
+
}: Readonly<
18
+
{
19
+
userDid: string;
20
+
userHandle: string;
21
+
photos: PhotoView[];
22
+
returnTo?: string;
23
+
selectedGallery?: Un$Typed<GalleryView>;
24
+
}
25
+
>) {
12
26
return (
13
27
<div class="flex flex-col px-4 pt-4 mb-4 space-y-4">
14
28
<Breadcrumb
15
29
items={[
16
30
returnTo
17
31
? { label: "Gallery", href: returnTo }
18
-
: { label: "Profile", href: profileLink(handle) },
32
+
: { label: "Profile", href: profileLink(userHandle) },
19
33
{ label: "Upload" },
20
34
]}
21
35
/>
22
-
<div>
23
-
Upload 10 photos at a time. Click{" "}
24
-
<button
25
-
type="button"
26
-
hx-get="/dialogs/gallery/new"
27
-
hx-target="#layout"
28
-
hx-swap="afterbegin"
29
-
class="font-semibold hover:underline cursor-pointer text-sky-500"
30
-
>
31
-
here
32
-
</button>{" "}
33
-
to create a gallery or add to existing galleries once you're done!
34
-
</div>
35
36
<form
36
37
hx-encoding="multipart/form-data"
37
38
_="on change from #file-input call Grain.uploadPage.uploadPhotos(me)"
38
39
>
40
+
<input hidden name="galleryUri" value={selectedGallery?.uri} />
39
41
<Button variant="primary" class="mb-4 w-full sm:w-fit" asChild>
40
42
<label>
41
-
<i class="fa fa-plus"></i> Add photos
43
+
<i class="fa fa-plus"></i>{" "}
44
+
{selectedGallery ? "Add photos to gallery" : "Add photos"}
42
45
<input
43
46
id="file-input"
44
47
class="hidden"
···
70
73
</button>
71
74
</label>
72
75
</form>
76
+
<div class="flex flex-col sm:flex-row items-center justify-between gap-2">
77
+
{selectedGallery
78
+
? (
79
+
<div className="flex-1 flex items-center my-2">
80
+
Showing photos for "{(selectedGallery?.record as Gallery)
81
+
.title}" (
82
+
<span id="photos-count">{photos.length}</span>
83
+
)
84
+
</div>
85
+
)
86
+
: (
87
+
<div className="flex-1 flex items-center my-2">
88
+
All photos (
89
+
<span id="photos-count">{photos.length}</span>
90
+
)
91
+
</div>
92
+
)}
93
+
<div class="flex items-center flex-col sm:flex-row gap-2 w-full justify-end flex-1">
94
+
{selectedGallery
95
+
? (
96
+
<Button variant="secondary" class="w-full sm:w-fit" asChild>
97
+
<a
98
+
href="/upload"
99
+
title="Clear gallery selection"
100
+
>
101
+
<i class="fa fa-close mr-2" />
102
+
Remove gallery filter
103
+
</a>
104
+
</Button>
105
+
)
106
+
: null}
107
+
{!selectedGallery
108
+
? <GallerySelectDialogButton userDid={userDid} />
109
+
: null}
110
+
{
111
+
/* {!selectedGallery && (
112
+
<Button variant="secondary" class="w-full sm:w-fit">
113
+
<i class="fa fa-plus"></i> Create gallery
114
+
</Button>
115
+
)} */
116
+
}
117
+
{
118
+
/* {selectedGallery
119
+
? (
120
+
<Button variant="secondary" asChild>
121
+
<a
122
+
class="w-full sm:w-fit"
123
+
href={galleryLink(
124
+
selectedGallery.creator.handle,
125
+
new AtUri(selectedGallery.uri).rkey,
126
+
)}
127
+
>
128
+
Go to gallery page
129
+
</a>
130
+
</Button>
131
+
)
132
+
: null} */
133
+
}
134
+
</div>
135
+
</div>
73
136
<div
74
137
id="image-preview"
75
138
class="w-full h-full grid grid-cols-2 sm:grid-cols-5 gap-2"
76
139
>
77
-
{photos.map((photo) => <PhotoPreview key={photo.cid} photo={photo} />)}
140
+
{photos.map((photo) => (
141
+
<PhotoPreview
142
+
key={photo.cid}
143
+
photo={photo}
144
+
selectedGallery={selectedGallery}
145
+
/>
146
+
))}
78
147
</div>
79
148
</div>
80
149
);
+156
src/input.css
+156
src/input.css
···
10
10
font-style: normal;
11
11
font-display: swap;
12
12
}
13
+
14
+
:root {
15
+
--focus-ring-light: var(--color-zinc-200);
16
+
--focus-ring-dark: var(--color-zinc-500);
17
+
--btn-border-radius: var(--radius-sm);
18
+
--input-border-radius: var(--radius-sm);
19
+
}
20
+
21
+
@layer base {
22
+
:focus-visible {
23
+
outline: none;
24
+
box-shadow: 0 0 0 2px var(--focus-ring-light);
25
+
border-radius: var(--radius-sm);
26
+
}
27
+
}
28
+
29
+
@media (prefers-color-scheme: dark) {
30
+
:focus-visible {
31
+
box-shadow: 0 0 0 2px var(--focus-ring-dark);
32
+
}
33
+
}
34
+
35
+
@layer components {
36
+
.grain-label {
37
+
display: block;
38
+
margin-bottom: --spacing(2);
39
+
}
40
+
41
+
.grain-input {
42
+
border: 1px solid var(--color-zinc-100);
43
+
padding-inline: --spacing(3);
44
+
padding-block: --spacing(2);
45
+
width: 100%;
46
+
background-color: var(--color-zinc-100);
47
+
color: var(--color-zinc-900);
48
+
font-size: var(--font-size-base);
49
+
line-height: var(--line-height-normal);
50
+
border-radius: var(--input-border-radius);
51
+
box-shadow: none;
52
+
outline: none;
53
+
}
54
+
55
+
.grain-input::placeholder {
56
+
color: var(--color-zinc-400);
57
+
}
58
+
59
+
.grain-input:disabled {
60
+
background-color: var(--color-zinc-100);
61
+
cursor: not-allowed;
62
+
opacity: 0.7;
63
+
}
64
+
65
+
.grain-input.grain-input-error {
66
+
border-color: var(--color-red-500);
67
+
}
68
+
69
+
@media (prefers-color-scheme: dark) {
70
+
.grain-input {
71
+
background-color: var(--color-zinc-800);
72
+
color: var(--color-zinc-50);
73
+
border-color: var(--color-zinc-800);
74
+
}
75
+
}
76
+
77
+
.grain-btn {
78
+
display: inline-block;
79
+
padding-inline: --spacing(3);
80
+
padding-block: --spacing(1.5);
81
+
font-size: var(--font-size-base);
82
+
font-weight: 500;
83
+
line-height: var(--line-height-normal);
84
+
text-align: center;
85
+
cursor: pointer;
86
+
border-radius: var(--btn-border-radius);
87
+
box-shadow: none;
88
+
outline: none;
89
+
90
+
&.grain-btn-primary {
91
+
background-color: var(--color-sky-500);
92
+
color: var(--color-white);
93
+
border: 1px solid var(--color-sky-500);
94
+
}
95
+
96
+
&.grain-btn-secondary {
97
+
background-color: var(--color-zinc-100);
98
+
color: var(--color-zinc-800);
99
+
border: 1px solid var(--color-zinc-100);
100
+
}
101
+
102
+
@media (prefers-color-scheme: dark) {
103
+
&.grain-btn-secondary {
104
+
background-color: var(--color-zinc-800);
105
+
color: var(--color-zinc-100);
106
+
border: 1px solid var(--color-zinc-800);
107
+
}
108
+
}
109
+
110
+
&.grain-btn-ghost {
111
+
background-color: transparent;
112
+
border: 1px solid transparent;
113
+
}
114
+
115
+
&.grain-btn-destructive {
116
+
background-color: var(--color-red-500);
117
+
color: var(--color-white);
118
+
border: 1px solid var(--color-red-500);
119
+
}
120
+
121
+
&.grain-btn-tab {
122
+
display: flex;
123
+
align-items: center;
124
+
justify-content: center;
125
+
min-width: 120px;
126
+
padding-inline: --spacing(3);
127
+
padding-block: --spacing(2);
128
+
cursor: pointer;
129
+
font-weight: var(--font-weight-semibold);
130
+
text-align: center;
131
+
132
+
&[aria-selected="true"] {
133
+
background-color: var(--color-zinc-100);
134
+
font-weight: 600;
135
+
}
136
+
137
+
@media (prefers-color-scheme: dark) {
138
+
&[aria-selected="true"] {
139
+
background-color: var(--color-zinc-800);
140
+
}
141
+
}
142
+
}
143
+
}
144
+
145
+
.grain-input:focus-visible,
146
+
.grain-btn:focus-visible {
147
+
box-shadow: 0 0 0 2px var(--focus-ring-light);
148
+
}
149
+
150
+
@media (prefers-color-scheme: dark) {
151
+
.grain-input:focus-visible,
152
+
.grain-btn:focus-visible {
153
+
box-shadow: 0 0 0 2px var(--focus-ring-dark);
154
+
}
155
+
}
156
+
157
+
body[data-scroll-locked] {
158
+
overflow: hidden !important;
159
+
overscroll-behavior: contain;
160
+
position: relative !important;
161
+
padding-left: 0px;
162
+
padding-top: 0px;
163
+
padding-right: 0px;
164
+
margin-left: 0;
165
+
margin-top: 0;
166
+
margin-right: 0px !important;
167
+
}
168
+
}
+98
src/lib/gallery.ts
+98
src/lib/gallery.ts
···
48
48
"social.grain.photo",
49
49
{
50
50
where: [{ field: "uri", in: photoUris }],
51
+
orderBy: [{ field: "createdAt", direction: "asc" }],
51
52
},
52
53
);
53
54
···
196
197
}
197
198
return Array.from(cameras);
198
199
}
200
+
201
+
export function queryGalleriesByName(
202
+
userDid: string,
203
+
nameQuery: string,
204
+
ctx: BffContext,
205
+
): GalleryView[] {
206
+
if (!nameQuery || !userDid) return [];
207
+
const { items: galleries } = ctx.indexService.getRecords<
208
+
WithBffMeta<Gallery>
209
+
>(
210
+
"social.grain.gallery",
211
+
{
212
+
where: [
213
+
{ field: "did", equals: userDid },
214
+
{ field: "title", contains: nameQuery },
215
+
],
216
+
orderBy: [{ field: "createdAt", direction: "desc" }],
217
+
},
218
+
);
219
+
if (!galleries.length) return [];
220
+
221
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
222
+
223
+
const profile = getActorProfile(userDid, ctx);
224
+
if (!profile) return [];
225
+
226
+
const uris = galleries.map((g) => g.uri);
227
+
const labels = ctx.indexService.queryLabels({ subjects: uris });
228
+
229
+
return galleries.map((gallery) =>
230
+
galleryToView(
231
+
gallery,
232
+
profile,
233
+
galleryPhotosMap.get(gallery.uri) ?? [],
234
+
labels,
235
+
)
236
+
);
237
+
}
238
+
239
+
export function getGalleryPhotos(
240
+
galleryUri: string,
241
+
ctx: BffContext,
242
+
): PhotoView[] {
243
+
if (!galleryUri) return [];
244
+
const { items: galleryItems } = ctx.indexService.getRecords<
245
+
WithBffMeta<GalleryItem>
246
+
>(
247
+
"social.grain.gallery.item",
248
+
{
249
+
where: [{ field: "gallery", equals: galleryUri }],
250
+
},
251
+
);
252
+
const photoUris = galleryItems.map((item) => item.item).filter(Boolean);
253
+
if (!photoUris.length) return [];
254
+
const { items: photos } = ctx.indexService.getRecords<WithBffMeta<Photo>>(
255
+
"social.grain.photo",
256
+
{
257
+
where: [{ field: "uri", in: photoUris }],
258
+
orderBy: [{ field: "createdAt", direction: "desc" }],
259
+
},
260
+
);
261
+
const { items: photosExif } = ctx.indexService.getRecords<
262
+
WithBffMeta<PhotoExif>
263
+
>(
264
+
"social.grain.photo.exif",
265
+
{
266
+
where: [{ field: "photo", in: photoUris }],
267
+
},
268
+
);
269
+
const photosMap = new Map<string, PhotoWithExif>();
270
+
const exifMap = new Map<string, WithBffMeta<PhotoExif>>();
271
+
for (const exif of photosExif) {
272
+
exifMap.set(exif.photo, exif);
273
+
}
274
+
for (const photo of photos) {
275
+
const exif = exifMap.get(photo.uri);
276
+
photosMap.set(photo.uri, exif ? { ...photo, exif } : photo);
277
+
}
278
+
// Get the gallery DID from the URI
279
+
const did = (() => {
280
+
try {
281
+
return new AtUri(galleryUri).hostname;
282
+
} catch {
283
+
return undefined;
284
+
}
285
+
})();
286
+
// Return PhotoView[] in the order of photo creation time (already sorted by SQL)
287
+
return photos
288
+
.map((photo) => {
289
+
const exif = exifMap.get(photo.uri);
290
+
if (did) {
291
+
return photoToView(did, exif ? { ...photo, exif } : photo, exif);
292
+
}
293
+
return undefined;
294
+
})
295
+
.filter(isPhotoView);
296
+
}
+49
src/lib/photo.ts
+49
src/lib/photo.ts
···
1
+
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
2
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
3
+
import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts";
1
4
import { Record as Photo } from "$lexicon/types/social/grain/photo.ts";
2
5
import { ExifView, PhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
6
import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts";
···
5
8
import { BffContext, WithBffMeta } from "@bigmoves/bff";
6
9
import { format, parseISO } from "date-fns";
7
10
import { PUBLIC_URL, USE_CDN } from "../env.ts";
11
+
import { getActorProfile } from "./actor.ts";
12
+
import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts";
8
13
9
14
export function getPhoto(
10
15
uri: string,
···
194
199
return aIdx - bIdx;
195
200
});
196
201
}
202
+
203
+
export function getPhotoGalleries(
204
+
photoUri: string,
205
+
ctx: BffContext,
206
+
): GalleryView[] {
207
+
const { items: galleryItems } = ctx.indexService.getRecords<
208
+
WithBffMeta<GalleryItem>
209
+
>(
210
+
"social.grain.gallery.item",
211
+
{
212
+
where: [{ field: "item", equals: photoUri }],
213
+
},
214
+
);
215
+
216
+
const galleryUris = Array.from(
217
+
new Set(galleryItems.map((item) => item.gallery)),
218
+
);
219
+
if (galleryUris.length === 0) return [];
220
+
221
+
const { items: galleries } = ctx.indexService.getRecords<
222
+
WithBffMeta<Gallery>
223
+
>(
224
+
"social.grain.gallery",
225
+
{
226
+
where: [{ field: "uri", in: galleryUris }],
227
+
},
228
+
);
229
+
if (!galleries.length) return [];
230
+
231
+
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
232
+
const labels = ctx.indexService.queryLabels({ subjects: galleryUris });
233
+
return galleries
234
+
.map((gallery) => {
235
+
const profile = getActorProfile(gallery.did, ctx);
236
+
if (!profile) return undefined;
237
+
return galleryToView(
238
+
gallery,
239
+
profile,
240
+
galleryPhotosMap.get(gallery.uri) ?? [],
241
+
labels ?? [],
242
+
);
243
+
})
244
+
.filter((g): g is GalleryView => Boolean(g));
245
+
}
+40
-51
src/main.tsx
+40
-51
src/main.tsx
···
4
4
import { LoginPage } from "./components/LoginPage.tsx";
5
5
import { PDS_HOST_URL } from "./env.ts";
6
6
import { onError } from "./lib/errors.ts";
7
-
import * as actionHandlers from "./routes/actions.tsx";
7
+
import * as actions from "./routes/actions.tsx";
8
8
import { handler as communityGuidelinesHandler } from "./routes/community_guidelines.tsx";
9
-
import * as dialogHandlers from "./routes/dialogs.tsx";
9
+
import * as dialogs from "./routes/dialogs.tsx";
10
10
import { handler as exploreHandler } from "./routes/explore.tsx";
11
11
import { handler as followersHandler } from "./routes/followers.tsx";
12
12
import { handler as followsHandler } from "./routes/follows.tsx";
13
13
import { handler as galleryHandler } from "./routes/gallery.tsx";
14
-
import * as legalHandlers from "./routes/legal.tsx";
14
+
import * as legal from "./routes/legal.tsx";
15
15
import { handler as notificationsHandler } from "./routes/notifications.tsx";
16
16
import { handler as onboardHandler } from "./routes/onboard.tsx";
17
17
import { handler as profileHandler } from "./routes/profile.tsx";
···
61
61
route("/upload", uploadHandler),
62
62
route("/onboard", onboardHandler),
63
63
route("/support", supportHandler),
64
-
route("/support/privacy", legalHandlers.privacyHandler),
65
-
route("/support/terms", legalHandlers.termsHandler),
66
-
route("/support/copyright", legalHandlers.copyrightHandler),
64
+
route("/support/privacy", legal.privacyHandler),
65
+
route("/support/terms", legal.termsHandler),
66
+
route("/support/copyright", legal.copyrightHandler),
67
67
route("/support/community-guidelines", communityGuidelinesHandler),
68
-
route("/dialogs/create-account", dialogHandlers.createAccount),
69
-
route("/dialogs/gallery/new", dialogHandlers.createGallery),
70
-
route("/dialogs/gallery/:rkey", dialogHandlers.editGallery),
71
-
route("/dialogs/gallery/:rkey/sort", dialogHandlers.sortGallery),
72
-
route("/dialogs/label/:src/:val", dialogHandlers.labelValueDefinition),
73
-
route("/dialogs/profile", dialogHandlers.editProfile),
74
-
route("/dialogs/avatar/:handle", dialogHandlers.avatar),
75
-
route("/dialogs/image", dialogHandlers.image),
76
-
route("/dialogs/photo/:rkey/alt", dialogHandlers.photoAlt),
68
+
route("/dialogs/create-account", dialogs.createAccount),
69
+
route("/dialogs/gallery/new", dialogs.createGallery),
70
+
route("/dialogs/gallery/:rkey", dialogs.editGallery),
71
+
route("/dialogs/gallery/:rkey/photos", dialogs.galleryPhotoSelect),
72
+
route("/dialogs/gallery/:rkey/edit", dialogs.editGalleryDetails),
73
+
route("/dialogs/gallery/:rkey/sort", dialogs.sortGallery),
74
+
route("/dialogs/gallery/:rkey/library", dialogs.galleryAddFromLibrary),
75
+
route("/dialogs/gallery/:did/select", dialogs.gallerySelect),
76
+
route("/dialogs/label/:src/:val", dialogs.labelValueDefinition),
77
+
route("/dialogs/profile", dialogs.editProfile),
78
+
route("/dialogs/avatar/:handle", dialogs.avatar),
79
+
route("/dialogs/image", dialogs.image),
80
+
route("/dialogs/photo/:rkey/remove", dialogs.photoRemove),
81
+
route("/dialogs/photo/:rkey/alt", dialogs.photoAlt),
82
+
route("/dialogs/photo/:rkey/exif", dialogs.photoExif),
83
+
route("/dialogs/photo/:did/:rkey/exif-overlay", dialogs.photoExifOverlay),
84
+
route("/dialogs/exif-info", dialogs.exifInfo),
85
+
route("/actions/update-seen", ["POST"], actions.updateSeen),
86
+
route("/actions/follow/:followeeDid", ["POST"], actions.follow),
87
+
route("/actions/follow/:followeeDid/:rkey", ["DELETE"], actions.unfollow),
88
+
route("/actions/create-edit", ["POST"], actions.galleryCreateEdit),
89
+
route("/actions/gallery/delete", ["POST"], actions.galleryDelete),
77
90
route(
78
-
"/dialogs/photo/:rkey/exif",
79
-
dialogHandlers.photoExif,
91
+
"/actions/gallery/:rkey/add-photos",
92
+
["PUT"],
93
+
actions.galleryAddPhotos,
80
94
),
81
-
route(
82
-
"/dialogs/photo/:did/:rkey/exif-overlay",
83
-
dialogHandlers.photoExifOverlay,
84
-
),
85
-
route(
86
-
"/dialogs/exif-info",
87
-
dialogHandlers.exifInfo,
88
-
),
89
-
route(
90
-
"/dialogs/photo-select/:galleryRkey",
91
-
dialogHandlers.galleryPhotoSelect,
92
-
),
93
-
route("/actions/update-seen", ["POST"], actionHandlers.updateSeen),
94
-
route("/actions/follow/:followeeDid", ["POST"], actionHandlers.follow),
95
-
route(
96
-
"/actions/follow/:followeeDid/:rkey",
97
-
["DELETE"],
98
-
actionHandlers.unfollow,
99
-
),
100
-
route("/actions/create-edit", ["POST"], actionHandlers.galleryCreateEdit),
101
-
route("/actions/gallery/delete", ["POST"], actionHandlers.galleryDelete),
102
95
route(
103
96
"/actions/gallery/:galleryRkey/add-photo/:photoRkey",
104
97
["PUT"],
105
-
actionHandlers.galleryAddPhoto,
98
+
actions.galleryAddPhoto,
106
99
),
107
100
route(
108
101
"/actions/gallery/:galleryRkey/remove-photo/:photoRkey",
109
102
["PUT"],
110
-
actionHandlers.galleryRemovePhoto,
111
-
),
112
-
route("/actions/photo/:rkey", ["PUT"], actionHandlers.photoEdit),
113
-
route("/actions/photo/:rkey", ["DELETE"], actionHandlers.photoDelete),
114
-
route("/actions/photo", ["POST"], actionHandlers.uploadPhoto),
115
-
route("/actions/favorite", ["POST"], actionHandlers.galleryFavorite),
116
-
route("/actions/profile", ["PUT"], actionHandlers.profileUpdate),
117
-
route(
118
-
"/actions/gallery/:rkey/sort",
119
-
["POST"],
120
-
actionHandlers.gallerySort,
103
+
actions.galleryRemovePhoto,
121
104
),
122
-
route("/actions/get-blob", ["GET"], actionHandlers.getBlob),
105
+
route("/actions/photo/:rkey", ["PUT"], actions.photoEdit),
106
+
route("/actions/photo/:rkey", ["DELETE"], actions.photoDelete),
107
+
route("/actions/photo", ["POST"], actions.uploadPhoto),
108
+
route("/actions/favorite", ["POST"], actions.galleryFavorite),
109
+
route("/actions/profile", ["PUT"], actions.profileUpdate),
110
+
route("/actions/gallery/:rkey/sort", ["POST"], actions.gallerySort),
111
+
route("/actions/get-blob", ["GET"], actions.getBlob),
123
112
route("/:did/:collection/:rkey", recordHandler),
124
113
],
125
114
});
+152
-23
src/routes/actions.tsx
+152
-23
src/routes/actions.tsx
···
2
2
import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts";
3
3
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
4
4
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
5
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
5
6
import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts";
6
7
import { Record as Photo } from "$lexicon/types/social/grain/photo.ts";
7
8
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
···
10
11
import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff";
11
12
import { FavoriteButton } from "../components/FavoriteButton.tsx";
12
13
import { FollowButton } from "../components/FollowButton.tsx";
14
+
import { GalleryEditPhotosDialog } from "../components/GalleryEditPhotosDialog.tsx";
13
15
import { GalleryInfo } from "../components/GalleryInfo.tsx";
14
16
import { GalleryLayout } from "../components/GalleryLayout.tsx";
15
17
import { PhotoPreview } from "../components/PhotoPreview.tsx";
16
18
import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx";
19
+
import { getActorPhotos } from "../lib/actor.ts";
17
20
import { getFollowers } from "../lib/follow.ts";
18
21
import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts";
19
22
import { getPhoto, photoToView } from "../lib/photo.ts";
20
23
import type { State } from "../state.ts";
21
-
import { galleryLink } from "../utils.ts";
24
+
import { galleryLink, profileLink, uploadPageLink } from "../utils.ts";
22
25
23
26
export const updateSeen: RouteHandler = (
24
27
_req,
···
127
130
_params,
128
131
ctx: BffContext<State>,
129
132
) => {
130
-
ctx.requireAuth();
133
+
const { handle } = ctx.requireAuth();
131
134
const formData = await req.formData();
132
135
const uri = formData.get("uri") as string;
133
136
await deleteGallery(uri, ctx);
134
-
return ctx.redirect("/");
137
+
return ctx.redirect(profileLink(handle));
138
+
};
139
+
140
+
export const galleryAddPhotos: RouteHandler = async (
141
+
req,
142
+
params,
143
+
ctx: BffContext<State>,
144
+
) => {
145
+
const { did } = ctx.requireAuth();
146
+
const galleryRkey = params.rkey;
147
+
const formData = await req.formData();
148
+
const uris = formData.getAll("photoUri") as string[];
149
+
const gallery = getGallery(did, galleryRkey, ctx);
150
+
if (!gallery) return ctx.next();
151
+
152
+
const creates = [];
153
+
let position = gallery.items?.length ?? 0;
154
+
for (const uri of uris) {
155
+
creates.push({
156
+
collection: "social.grain.gallery.item",
157
+
data: {
158
+
gallery: gallery.uri,
159
+
item: uri,
160
+
createdAt: new Date().toISOString(),
161
+
position,
162
+
},
163
+
});
164
+
position++;
165
+
}
166
+
await ctx.createRecords<WithBffMeta<GalleryItem>>(creates);
167
+
168
+
const updatedGallery = getGallery(did, galleryRkey, ctx);
169
+
if (!updatedGallery) return ctx.next();
170
+
171
+
return ctx.html(
172
+
<>
173
+
<GalleryEditPhotosDialog
174
+
galleryUri={gallery.uri}
175
+
photos={updatedGallery?.items
176
+
?.filter(isPhotoView) ?? []}
177
+
/>
178
+
<div hx-swap-oob="beforeend:#gallery-container">
179
+
{updatedGallery.items?.filter(isPhotoView).filter((i) =>
180
+
uris.includes(i.uri)
181
+
).map((item) => (
182
+
<GalleryLayout.Item
183
+
key={item.uri}
184
+
photo={item}
185
+
gallery={updatedGallery}
186
+
/>
187
+
))}
188
+
</div>
189
+
<div hx-swap-oob="outerHTML:#gallery-info">
190
+
<GalleryInfo gallery={updatedGallery} />
191
+
</div>
192
+
</>,
193
+
);
135
194
};
136
195
137
196
export const galleryAddPhoto: RouteHandler = async (
138
-
_req,
197
+
req,
139
198
params,
140
199
ctx: BffContext<State>,
141
200
) => {
142
201
const { did } = ctx.requireAuth();
202
+
const url = new URL(req.url);
203
+
const page = url.searchParams.get("page") || undefined;
143
204
const galleryRkey = params.galleryRkey;
144
205
const photoRkey = params.photoRkey;
145
206
const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`;
146
207
const photoUri = `at://${did}/social.grain.photo/${photoRkey}`;
147
208
const gallery = getGallery(did, galleryRkey, ctx);
148
209
const photo = getPhoto(photoUri, ctx);
210
+
149
211
if (!gallery || !photo) return ctx.next();
212
+
150
213
if (
151
214
gallery.items
152
215
?.filter(isPhotoView)
153
216
.some((item) => item.uri === photoUri)
154
217
) {
218
+
if (page === "upload") {
219
+
return ctx.redirect(uploadPageLink(galleryRkey));
220
+
}
155
221
return new Response(null, { status: 500 });
156
222
}
157
-
await ctx.createRecord<Gallery>("social.grain.gallery.item", {
223
+
224
+
await ctx.createRecord<GalleryItem>("social.grain.gallery.item", {
158
225
gallery: galleryUri,
159
226
item: photoUri,
160
227
position: gallery.items?.length ?? 0,
161
228
createdAt: new Date().toISOString(),
162
229
});
163
-
gallery.items = [
164
-
...(gallery.items ?? []),
165
-
photo,
166
-
];
230
+
231
+
if (page === "upload") {
232
+
return ctx.redirect(uploadPageLink(galleryRkey));
233
+
}
234
+
167
235
return ctx.html(
168
236
<>
169
237
<div hx-swap-oob="beforeend:#gallery-container">
···
178
246
</div>
179
247
<PhotoSelectButton
180
248
galleryUri={galleryUri}
181
-
itemUris={gallery.items?.filter(isPhotoView).map((item) => item.uri) ??
182
-
[]}
183
249
photo={photo}
184
250
/>
185
251
</>,
···
187
253
};
188
254
189
255
export const galleryRemovePhoto: RouteHandler = async (
190
-
_req,
256
+
req,
191
257
params,
192
258
ctx: BffContext<State>,
193
259
) => {
194
260
const { did } = ctx.requireAuth();
261
+
const url = new URL(req.url);
262
+
const selectedGallery = url.searchParams.get("selectedGallery");
195
263
const galleryRkey = params.galleryRkey;
196
264
const photoRkey = params.photoRkey;
197
265
const galleryUri = `at://${did}/social.grain.gallery/${galleryRkey}`;
···
225
293
<div hx-swap-oob="outerHTML:#gallery-info">
226
294
<GalleryInfo gallery={gallery} />
227
295
</div>
228
-
<PhotoSelectButton
229
-
galleryUri={galleryUri}
230
-
itemUris={gallery.items?.filter(isPhotoView).map((item) => item.uri) ??
231
-
[]}
232
-
photo={photoToView(photo.did, photo)}
233
-
/>
296
+
{/* Remove from gallery container or image previews */}
297
+
{selectedGallery
298
+
? <div hx-swap-oob={`delete:#photo-${photoRkey}`} />
299
+
: null}
234
300
</>,
235
301
);
236
302
};
···
257
323
};
258
324
259
325
export const photoDelete: RouteHandler = async (
260
-
_req,
326
+
req,
261
327
params,
262
328
ctx: BffContext<State>,
263
329
) => {
264
330
const { did } = ctx.requireAuth();
331
+
const url = new URL(req.url);
332
+
const selectedGallery = url.searchParams.get("selectedGallery");
333
+
const selectedGalleryRkey = selectedGallery
334
+
? new AtUri(selectedGallery).rkey
335
+
: undefined;
265
336
const deleteUris: string[] = [];
266
337
await ctx.deleteRecord(
267
338
`at://${did}/social.grain.photo/${params.rkey}`,
···
317
388
for (const uri of deleteUris) {
318
389
await ctx.deleteRecord(uri);
319
390
}
320
-
return new Response(null, { status: 200 });
391
+
return ctx.redirect(uploadPageLink(selectedGalleryRkey));
321
392
};
322
393
323
394
export const galleryFavorite: RouteHandler = async (
···
517
588
const width = Number(formData.get("width")) || undefined;
518
589
const height = Number(formData.get("height")) || undefined;
519
590
const exifJsonString = formData.get("exif") as string;
591
+
const galleryUri = formData.get("galleryUri") as string || undefined;
592
+
const page = formData.get("page") as string || undefined;
520
593
let exif = undefined;
521
594
522
595
if (exifJsonString) {
···
570
643
return new Response("Photo not found after creation", { status: 404 });
571
644
}
572
645
646
+
let gallery: GalleryView | undefined = undefined;
647
+
if (galleryUri) {
648
+
gallery = getGallery(did, new AtUri(galleryUri).rkey, ctx) ?? undefined;
649
+
if (gallery) {
650
+
await ctx.createRecord<GalleryItem>("social.grain.gallery.item", {
651
+
gallery: galleryUri,
652
+
item: photoUri,
653
+
position: gallery.items?.length ?? 0,
654
+
createdAt: new Date().toISOString(),
655
+
});
656
+
}
657
+
}
658
+
573
659
let exifRecord: WithBffMeta<PhotoExif> | undefined = undefined;
574
660
if (exifUri) {
575
661
exifRecord = ctx.indexService.getRecord<WithBffMeta<PhotoExif>>(
···
577
663
);
578
664
}
579
665
666
+
if (page === "gallery" && gallery && galleryUri) {
667
+
const rkey = new AtUri(gallery.uri).rkey;
668
+
// updated gallery post gallery item creation
669
+
const updatedGallery = getGallery(did, rkey, ctx);
670
+
if (!updatedGallery) {
671
+
return ctx.next();
672
+
}
673
+
const p = photoToView(did, photo, exifRecord);
674
+
return ctx.html(
675
+
<>
676
+
<PhotoSelectButton
677
+
galleryUri={gallery.uri}
678
+
photo={p}
679
+
/>
680
+
<div hx-swap-oob="beforeend:#gallery-container">
681
+
<GalleryLayout.Item
682
+
key={photo.cid}
683
+
photo={p}
684
+
gallery={updatedGallery}
685
+
/>
686
+
</div>
687
+
<div hx-swap-oob="outerHTML:#gallery-info">
688
+
<GalleryInfo gallery={updatedGallery} />
689
+
</div>
690
+
</>,
691
+
);
692
+
}
693
+
694
+
// @TODO: Use count queries
695
+
let photosCount = 0;
696
+
if (gallery && galleryUri) {
697
+
const rkey = new AtUri(gallery.uri).rkey;
698
+
const updatedGallery = getGallery(did, rkey, ctx);
699
+
photosCount = updatedGallery?.items?.length ?? 0;
700
+
} else {
701
+
const photos = getActorPhotos(did, ctx);
702
+
photosCount = photos.length;
703
+
}
704
+
580
705
return ctx.html(
581
-
<PhotoPreview
582
-
photo={photoToView(did, photo, exifRecord)}
583
-
/>,
706
+
<>
707
+
<PhotoPreview
708
+
photo={photoToView(did, photo, exifRecord)}
709
+
selectedGallery={gallery}
710
+
/>
711
+
<div hx-swap-oob="photos-count">{photosCount}</div>
712
+
</>,
584
713
);
585
714
} catch (e) {
586
715
console.error("Error in uploadStart:", e);
+113
-17
src/routes/dialogs.tsx
+113
-17
src/routes/dialogs.tsx
···
1
1
import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts";
2
-
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
3
2
import { Record as Photo } from "$lexicon/types/social/grain/photo.ts";
4
3
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
5
4
import { AtUri } from "@atproto/syntax";
···
7
6
import { wrap } from "popmotion";
8
7
import { AvatarDialog } from "../components/AvatarDialog.tsx";
9
8
import { CreateAccountDialog } from "../components/CreateAccountDialog.tsx";
9
+
import { EditGalleryDialog } from "../components/EditGalleryDialog.tsx";
10
10
import { ExifInfoDialog } from "../components/ExifInfoDialog.tsx";
11
11
import { ExifOverlayDialog } from "../components/ExifOverlayDialog.tsx";
12
-
import { GalleryCreateEditDialog } from "../components/GalleryCreateEditDialog.tsx";
12
+
import { GalleryDetailsDialog } from "../components/GalleryDetailsDialog.tsx";
13
+
import { GalleryEditPhotosDialog } from "../components/GalleryEditPhotosDialog.tsx";
14
+
import {
15
+
GallerySelectDialog,
16
+
GallerySelectDialogSearchResults,
17
+
} from "../components/GallerySelectDialog.tsx";
13
18
import { GallerySortDialog } from "../components/GallerySortDialog.tsx";
14
19
import { LabelDefinitionDialog } from "../components/LabelDefinitionDialog.tsx";
20
+
import { LibraryPhotoSelectDialog } from "../components/LibraryPhotoSelectDialog.tsx";
15
21
import { PhotoAltDialog } from "../components/PhotoAltDialog.tsx";
16
22
import { PhotoDialog } from "../components/PhotoDialog.tsx";
17
23
import { PhotoExifDialog } from "../components/PhotoExifDialog.tsx";
18
-
import { PhotoSelectDialog } from "../components/PhotoSelectDialog.tsx";
19
24
import { ProfileDialog } from "../components/ProfileDialog.tsx";
20
-
import { getActorPhotos, getActorProfile } from "../lib/actor.ts";
21
-
import { getGallery, getGalleryItemsAndPhotos } from "../lib/gallery.ts";
25
+
import { RemovePhotoDialog } from "../components/RemovePhotoDialog.tsx";
26
+
import {
27
+
getActorGalleries,
28
+
getActorPhotos,
29
+
getActorProfile,
30
+
} from "../lib/actor.ts";
31
+
import { getGallery, queryGalleriesByName } from "../lib/gallery.ts";
22
32
import { atprotoLabelValueDefinitions } from "../lib/moderation.ts";
23
-
import { getPhoto, photoToView } from "../lib/photo.ts";
33
+
import { getPhoto, getPhotoGalleries, photoToView } from "../lib/photo.ts";
24
34
import type { State } from "../state.ts";
25
35
26
36
export const createGallery: RouteHandler = (
···
29
39
ctx: BffContext<State>,
30
40
) => {
31
41
ctx.requireAuth();
32
-
return ctx.html(<GalleryCreateEditDialog />);
42
+
return ctx.html(<GalleryDetailsDialog />);
33
43
};
34
44
35
45
export const editGallery: RouteHandler = (
···
40
50
const { handle } = ctx.requireAuth();
41
51
const rkey = params.rkey;
42
52
const gallery = getGallery(handle, rkey, ctx);
43
-
return ctx.html(<GalleryCreateEditDialog gallery={gallery} />);
53
+
if (!gallery) return ctx.next();
54
+
return ctx.html(<EditGalleryDialog gallery={gallery} />);
55
+
};
56
+
57
+
export const editGalleryDetails: RouteHandler = (
58
+
_req,
59
+
params,
60
+
ctx: BffContext<State>,
61
+
) => {
62
+
const { handle } = ctx.requireAuth();
63
+
const rkey = params.rkey;
64
+
const gallery = getGallery(handle, rkey, ctx);
65
+
return ctx.html(<GalleryDetailsDialog gallery={gallery} />);
44
66
};
45
67
46
68
export const sortGallery: RouteHandler = (
···
172
194
ctx: BffContext<State>,
173
195
) => {
174
196
const { did } = ctx.requireAuth();
175
-
const photos = getActorPhotos(did, ctx);
176
-
const galleryUri = `at://${did}/social.grain.gallery/${params.galleryRkey}`;
177
-
const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>(
178
-
galleryUri,
197
+
const galleryUri = `at://${did}/social.grain.gallery/${params.rkey}`;
198
+
const gallery = getGallery(did, params.rkey, ctx);
199
+
if (!gallery) return ctx.next();
200
+
return ctx.html(
201
+
<GalleryEditPhotosDialog
202
+
galleryUri={galleryUri}
203
+
photos={gallery?.items
204
+
?.filter(isPhotoView) ?? []}
205
+
/>,
179
206
);
207
+
};
208
+
209
+
export const galleryAddFromLibrary: RouteHandler = (
210
+
_req,
211
+
params,
212
+
ctx: BffContext<State>,
213
+
) => {
214
+
const { did } = ctx.requireAuth();
215
+
const galleryUri = `at://${did}/social.grain.gallery/${params.rkey}`;
216
+
const gallery = getGallery(did, params.rkey, ctx);
180
217
if (!gallery) return ctx.next();
181
-
const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, [gallery]);
182
-
const itemUris =
183
-
galleryPhotosMap.get(galleryUri)?.map((photo) => photo.uri) ?? [];
218
+
const galleryPhotoUris = new Set(
219
+
gallery.items?.filter(isPhotoView).map((item) => item.uri),
220
+
);
221
+
const photos = getActorPhotos(did, ctx).filter((photo) => {
222
+
return !galleryPhotoUris.has(photo.uri);
223
+
});
184
224
return ctx.html(
185
-
<PhotoSelectDialog
225
+
<LibraryPhotoSelectDialog
186
226
galleryUri={galleryUri}
187
-
itemUris={itemUris}
188
227
photos={photos}
189
228
/>,
190
229
);
···
235
274
<ExifInfoDialog />,
236
275
);
237
276
};
277
+
278
+
export const gallerySelect: RouteHandler = (
279
+
req,
280
+
_params,
281
+
ctx: BffContext<State>,
282
+
) => {
283
+
const { did } = ctx.requireAuth();
284
+
const url = new URL(req.url);
285
+
const photoUri = url.searchParams.get("photoUri") as string || undefined;
286
+
const galleries = getActorGalleries(did, ctx);
287
+
const query = url.searchParams.get("q");
288
+
289
+
if (query) {
290
+
const galleries = queryGalleriesByName(did, query, ctx);
291
+
return ctx.html(
292
+
<GallerySelectDialogSearchResults galleries={galleries} />,
293
+
);
294
+
}
295
+
296
+
if (query === "") {
297
+
// no-op keep the original dialog open
298
+
return ctx.html(<GallerySelectDialogSearchResults galleries={galleries} />);
299
+
}
300
+
301
+
return ctx.html(
302
+
<GallerySelectDialog
303
+
photoUri={photoUri}
304
+
userDid={did}
305
+
galleries={galleries ?? []}
306
+
/>,
307
+
);
308
+
};
309
+
310
+
export const photoRemove: RouteHandler = (
311
+
req,
312
+
params,
313
+
ctx: BffContext<State>,
314
+
) => {
315
+
const { did } = ctx.requireAuth();
316
+
const url = new URL(req.url);
317
+
const selectedGalleryUri = url.searchParams.get("selectedGallery");
318
+
const rkey = params.rkey;
319
+
const photoUri = `at://${did}/social.grain.photo/${rkey}`;
320
+
const galleries = getPhotoGalleries(photoUri, ctx);
321
+
322
+
const selectedGallery = selectedGalleryUri
323
+
? galleries.find((gallery) => gallery.uri === selectedGalleryUri)
324
+
: undefined;
325
+
326
+
return ctx.html(
327
+
<RemovePhotoDialog
328
+
photoUri={photoUri}
329
+
galleries={galleries ?? []}
330
+
selectedGallery={selectedGallery}
331
+
/>,
332
+
);
333
+
};
+1
-2
src/routes/explore.tsx
+1
-2
src/routes/explore.tsx
···
2
2
import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts";
3
3
import { Un$Typed } from "$lexicon/util.ts";
4
4
import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff";
5
-
import { Input } from "@bigmoves/bff/components";
6
5
import { ComponentChildren } from "preact";
7
6
import { ActorAvatar } from "../components/ActorAvatar.tsx";
7
+
import { Input } from "../components/Input.tsx";
8
8
import { LabelerAvatar } from "../components/LabelerAvatar.tsx";
9
9
import { profileToView } from "../lib/actor.ts";
10
10
import { getPageMeta } from "../meta.ts";
···
54
54
<div class="my-4">
55
55
<Input
56
56
name="q"
57
-
class="dark:bg-zinc-800 dark:text-white border-zinc-100 bg-zinc-100 dark:border-zinc-800"
58
57
placeholder="Search for users"
59
58
hx-get="/explore"
60
59
hx-target="#search-results"
+11
-2
src/routes/upload.tsx
+11
-2
src/routes/upload.tsx
···
1
1
import { BffContext, RouteHandler } from "@bigmoves/bff";
2
2
import { UploadPage } from "../components/UploadPage.tsx";
3
3
import { getActorPhotos } from "../lib/actor.ts";
4
+
import { getGallery, getGalleryPhotos } from "../lib/gallery.ts";
4
5
import { getPageMeta } from "../meta.ts";
5
6
import type { State } from "../state.ts";
6
7
import { galleryLink } from "../utils.ts";
···
12
13
) => {
13
14
const { did, handle } = ctx.requireAuth();
14
15
const url = new URL(req.url);
16
+
const selectedGalleryRkey = url.searchParams.get("gallery") ?? "";
17
+
const selectedGalleryUri =
18
+
`at://${did}/social.grain.gallery/${selectedGalleryRkey}`;
15
19
const galleryRkey = url.searchParams.get("returnTo");
16
-
const photos = getActorPhotos(did, ctx);
20
+
const photos = selectedGalleryRkey
21
+
? getGalleryPhotos(selectedGalleryUri, ctx)
22
+
: getActorPhotos(did, ctx);
23
+
const selectedGallery = getGallery(did, selectedGalleryRkey, ctx);
17
24
ctx.state.meta = [{ title: "Upload — Grain" }, ...getPageMeta("/upload")];
18
25
return ctx.render(
19
26
<UploadPage
20
-
handle={handle}
27
+
userDid={did}
28
+
userHandle={handle}
21
29
photos={photos}
22
30
returnTo={galleryRkey ? galleryLink(handle, galleryRkey) : undefined}
31
+
selectedGallery={selectedGallery ?? undefined}
23
32
/>,
24
33
);
25
34
};
+54
src/static/exif.ts
+54
src/static/exif.ts
···
1
+
export const tags = [
2
+
"DateTimeOriginal",
3
+
"ExposureTime",
4
+
"FNumber",
5
+
"Flash",
6
+
"FocalLengthIn35mmFormat",
7
+
"ISO",
8
+
"LensMake",
9
+
"LensModel",
10
+
"Make",
11
+
"Model",
12
+
];
13
+
14
+
export const SCALE_FACTOR = 1000000;
15
+
16
+
export type Exif = Record<
17
+
string,
18
+
number | string | boolean | Array<number | string> | undefined | Date
19
+
>;
20
+
21
+
export function normalizeExif(
22
+
exif: Exif,
23
+
scale: number = SCALE_FACTOR,
24
+
): Exif {
25
+
const normalized: Record<
26
+
string,
27
+
number | string | boolean | Array<number | string> | undefined
28
+
> = {};
29
+
30
+
for (const [key, value] of Object.entries(exif)) {
31
+
const camelKey = key[0].toLowerCase() + key.slice(1);
32
+
33
+
if (typeof value === "number") {
34
+
normalized[camelKey] = Math.round(value * scale);
35
+
} else if (Array.isArray(value)) {
36
+
normalized[camelKey] = value.map((v) =>
37
+
typeof v === "number" ? Math.round(v * scale) : v
38
+
);
39
+
} else if (value instanceof Date) {
40
+
normalized[camelKey] = value.toISOString();
41
+
} else if (typeof value === "string") {
42
+
normalized[camelKey] = value;
43
+
} else if (typeof value === "boolean") {
44
+
normalized[camelKey] = value;
45
+
} else if (value === undefined) {
46
+
normalized[camelKey] = undefined;
47
+
} else {
48
+
// fallback for unknown types
49
+
normalized[camelKey] = String(value);
50
+
}
51
+
}
52
+
53
+
return normalized;
54
+
}
+137
src/static/gallery_photos_dialog.ts
+137
src/static/gallery_photos_dialog.ts
···
1
+
import {
2
+
dataURLToBlob,
3
+
doResize,
4
+
readFileAsDataURL,
5
+
} from "@bigmoves/bff/browser";
6
+
import exifr from "exifr";
7
+
import htmx from "htmx.org";
8
+
import { Exif, normalizeExif, tags as supportedTags } from "./exif.ts";
9
+
10
+
export class GalleryPhotosDialog {
11
+
public async uploadPhotos(formElement: HTMLFormElement): Promise<void> {
12
+
const formData = new FormData(formElement);
13
+
const fileList = formData.getAll("files") as File[] ?? [];
14
+
const parseExif = formData.get("parseExif") === "on";
15
+
const galleryUri = formData.get("galleryUri") as string;
16
+
const page = formData.get("page") as string;
17
+
18
+
if (fileList.length > 10) {
19
+
alert("You can only upload 10 photos at a time");
20
+
return;
21
+
}
22
+
23
+
const uploadPromises = fileList.map(async (file) => {
24
+
let fileDataUri: string | ArrayBuffer | null;
25
+
let tags: Exif | undefined = undefined;
26
+
let resized;
27
+
28
+
try {
29
+
fileDataUri = await readFileAsDataURL(file);
30
+
if (fileDataUri === null || typeof fileDataUri !== "string") {
31
+
console.error("File data URL is not a string:", fileDataUri);
32
+
alert("Error reading file.");
33
+
return;
34
+
}
35
+
} catch (err) {
36
+
console.error("Error reading file as Data URL:", err);
37
+
alert("Error reading file.");
38
+
return;
39
+
}
40
+
41
+
if (parseExif) {
42
+
try {
43
+
const rawTags = await exifr.parse(file, { pick: supportedTags });
44
+
console.log("EXIF tags:", await exifr.parse(file));
45
+
tags = normalizeExif(rawTags);
46
+
} catch (err) {
47
+
console.error("Error reading EXIF data:", err);
48
+
}
49
+
}
50
+
51
+
try {
52
+
resized = await doResize(fileDataUri, {
53
+
width: 2000,
54
+
height: 2000,
55
+
maxSize: 1000 * 1000, // 1MB
56
+
mode: "contain",
57
+
});
58
+
} catch (err) {
59
+
console.error("Error resizing image:", err);
60
+
alert("Error resizing image.");
61
+
return;
62
+
}
63
+
64
+
const blob = dataURLToBlob(resized.path);
65
+
66
+
const fd = new FormData();
67
+
fd.append("file", blob, file.name);
68
+
fd.append("width", String(resized.width));
69
+
fd.append("height", String(resized.height));
70
+
71
+
if (tags) {
72
+
fd.append("exif", JSON.stringify(tags));
73
+
}
74
+
75
+
if (galleryUri) {
76
+
fd.append("galleryUri", galleryUri);
77
+
}
78
+
79
+
if (page) {
80
+
fd.append("page", page);
81
+
}
82
+
83
+
const response = await fetch(`/actions/photo`, {
84
+
method: "POST",
85
+
body: fd,
86
+
});
87
+
88
+
if (!response.ok) {
89
+
alert(await response.text());
90
+
return;
91
+
}
92
+
93
+
const html = await response.text();
94
+
const temp = document.createElement("div");
95
+
temp.innerHTML = html;
96
+
97
+
const preview = document.querySelector("#image-preview");
98
+
if (preview) {
99
+
const child = temp.firstElementChild;
100
+
if (child) {
101
+
preview.appendChild(child);
102
+
}
103
+
htmx.process(preview);
104
+
}
105
+
106
+
const galleryContainer = document.querySelector(
107
+
"#gallery-container",
108
+
);
109
+
if (galleryContainer) {
110
+
const child = temp.firstElementChild;
111
+
if (child) {
112
+
galleryContainer.appendChild(child.children[0]);
113
+
}
114
+
htmx.process(galleryContainer);
115
+
}
116
+
117
+
const galleryInfo = document.querySelector(
118
+
"#gallery-info",
119
+
);
120
+
if (galleryInfo) {
121
+
const child = temp.children[1];
122
+
if (child) {
123
+
galleryInfo.replaceWith(child.children[0]);
124
+
}
125
+
htmx.process(galleryInfo);
126
+
}
127
+
});
128
+
129
+
await Promise.all(uploadPromises);
130
+
131
+
// Clear the file input after upload
132
+
const fileInput = formElement.querySelector("input[type='file']");
133
+
if (fileInput instanceof HTMLInputElement) {
134
+
fileInput.value = "";
135
+
}
136
+
}
137
+
}
+7
-1
src/static/mod.ts
+7
-1
src/static/mod.ts
···
2
2
import _hyperscript from "hyperscript.org";
3
3
import Sortable from "sortablejs";
4
4
import { GalleryLayout } from "./gallery_layout.ts";
5
+
import { GalleryPhotosDialog } from "./gallery_photos_dialog.ts";
5
6
import { PhotoDialog } from "./photo_dialog.ts";
6
7
import { ProfileDialog } from "./profile_dialog.ts";
7
8
import { UploadPage } from "./upload_page.ts";
8
9
9
-
const galleryLayout = new GalleryLayout({ layoutMode: "justified" });
10
+
const galleryLayout = new GalleryLayout({
11
+
layoutMode: "justified",
12
+
spacing: 4,
13
+
});
10
14
galleryLayout.init();
11
15
12
16
htmx.onLoad(function (element) {
···
31
35
uploadPage?: UploadPage;
32
36
profileDialog?: ProfileDialog;
33
37
galleryLayout?: GalleryLayout;
38
+
galleryPhotosDialog?: GalleryPhotosDialog;
34
39
};
35
40
};
36
41
···
40
45
g.Grain = g.Grain ?? {};
41
46
g.Grain.uploadPage = new UploadPage();
42
47
g.Grain.profileDialog = new ProfileDialog();
48
+
g.Grain.galleryPhotosDialog = new GalleryPhotosDialog();
43
49
g.Grain.galleryLayout = galleryLayout;
+18
-48
src/static/upload_page.ts
+18
-48
src/static/upload_page.ts
···
6
6
import exifr from "exifr";
7
7
import htmx from "htmx.org";
8
8
import hyperscript from "hyperscript.org";
9
-
import { tags as supportedTags } from "./tags.ts";
9
+
import { Exif, normalizeExif, tags as supportedTags } from "./exif.ts";
10
10
11
11
export class UploadPage {
12
12
public async uploadPhotos(formElement: HTMLFormElement): Promise<void> {
13
13
const formData = new FormData(formElement);
14
14
const fileList = formData.getAll("files") as File[] ?? [];
15
15
const parseExif = formData.get("parseExif") === "on";
16
+
const galleryUri = formData.get("galleryUri") as string;
16
17
17
18
if (fileList.length > 10) {
18
19
alert("You can only upload 10 photos at a time");
···
63
64
const blob = dataURLToBlob(resized.path);
64
65
65
66
const fd = new FormData();
66
-
fd.append("file", blob, (file as File).name);
67
+
fd.append("file", blob, file.name);
67
68
fd.append("width", String(resized.width));
68
69
fd.append("height", String(resized.height));
69
70
70
71
if (tags) {
71
72
fd.append("exif", JSON.stringify(tags));
73
+
}
74
+
75
+
if (galleryUri) {
76
+
fd.append("galleryUri", galleryUri);
72
77
}
73
78
74
79
const response = await fetch("/actions/photo", {
···
99
104
const deleteButton = preview.querySelector(
100
105
`#delete-photo-${photoId}`,
101
106
);
102
-
if (!deleteButton) {
103
-
return;
107
+
if (deleteButton) {
108
+
htmx.process(deleteButton);
109
+
hyperscript.processNode(deleteButton);
110
+
}
111
+
}
112
+
113
+
const photosCount = document.querySelector("#photos-count");
114
+
if (photosCount) {
115
+
const firstChild = temp.firstElementChild;
116
+
if (firstChild) {
117
+
photosCount.replaceWith(firstChild.innerHTML);
104
118
}
105
-
htmx.process(deleteButton);
106
-
hyperscript.processNode(deleteButton);
107
119
}
108
120
});
109
121
···
116
128
}
117
129
}
118
130
}
119
-
120
-
const SCALE_FACTOR = 1000000;
121
-
122
-
type Exif = Record<
123
-
string,
124
-
number | string | boolean | Array<number | string> | undefined | Date
125
-
>;
126
-
127
-
function normalizeExif(
128
-
exif: Exif,
129
-
scale: number = SCALE_FACTOR,
130
-
): Exif {
131
-
const normalized: Record<
132
-
string,
133
-
number | string | boolean | Array<number | string> | undefined
134
-
> = {};
135
-
136
-
for (const [key, value] of Object.entries(exif)) {
137
-
const camelKey = key[0].toLowerCase() + key.slice(1);
138
-
139
-
if (typeof value === "number") {
140
-
normalized[camelKey] = Math.round(value * scale);
141
-
} else if (Array.isArray(value)) {
142
-
normalized[camelKey] = value.map((v) =>
143
-
typeof v === "number" ? Math.round(v * scale) : v
144
-
);
145
-
} else if (value instanceof Date) {
146
-
normalized[camelKey] = value.toISOString();
147
-
} else if (typeof value === "string") {
148
-
normalized[camelKey] = value;
149
-
} else if (typeof value === "boolean") {
150
-
normalized[camelKey] = value;
151
-
} else if (value === undefined) {
152
-
normalized[camelKey] = undefined;
153
-
} else {
154
-
// fallback for unknown types
155
-
normalized[camelKey] = String(value);
156
-
}
157
-
}
158
-
159
-
return normalized;
160
-
}
+5
src/utils.ts
+5
src/utils.ts
···
27
27
return `${Math.max(1, minutes)}m`;
28
28
}
29
29
30
+
export function uploadPageLink(selectedGalleryRkey?: string) {
31
+
return "/upload" +
32
+
(selectedGalleryRkey ? "?gallery=" + selectedGalleryRkey : "");
33
+
}
34
+
30
35
export function profileLink(handle: string) {
31
36
return `/profile/${handle}`;
32
37
}
+1
-1
sync.sh
+1
-1
sync.sh
···
2
2
3
3
# Helpful when running local-infra. Specify the repos you've created on a local pds instance.
4
4
5
-
REPOS="did:plc:gdvspmipkels2qp43m4czqhp"
5
+
REPOS="did:plc:yyz2m2gxnbaxoru2sepbltxv"
6
6
COLLECTIONS="social.grain.gallery,social.grain.actor.profile,social.grain.photo,social.grain.favorite,social.grain.gallery.item,social.grain.graph.follow,social.grain.photo.exif"
7
7
EXTERNAL_COLLECTIONS="app.bsky.actor.profile,app.bsky.graph.follow,sh.tangled.graph.follow,sh.tangled.actor.profile"
8
8