+20
-1
__generated__/lexicons.ts
+20
-1
__generated__/lexicons.ts
···
2527
2527
refs: ['lex:social.grain.photo.defs#photoView'],
2528
2528
},
2529
2529
},
2530
+
favCount: {
2531
+
type: 'integer',
2532
+
},
2530
2533
labels: {
2531
2534
type: 'array',
2532
2535
items: {
···
2537
2540
indexedAt: {
2538
2541
type: 'string',
2539
2542
format: 'datetime',
2543
+
},
2544
+
viewer: {
2545
+
type: 'ref',
2546
+
ref: 'lex:social.grain.gallery.defs#viewerState',
2547
+
},
2548
+
},
2549
+
},
2550
+
viewerState: {
2551
+
type: 'object',
2552
+
description:
2553
+
"Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.",
2554
+
properties: {
2555
+
fav: {
2556
+
type: 'string',
2557
+
format: 'at-uri',
2540
2558
},
2541
2559
},
2542
2560
},
···
3011
3029
defs: {
3012
3030
main: {
3013
3031
type: 'record',
3014
-
description: 'Basic EXIF metadata for a photo',
3032
+
description:
3033
+
'Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.',
3015
3034
key: 'tid',
3016
3035
record: {
3017
3036
type: 'object',
+112
-1
deno.lock
+112
-1
deno.lock
···
38
38
"npm:@atproto/common@~0.4.10": "0.4.11",
39
39
"npm:@atproto/identity@~0.4.7": "0.4.8",
40
40
"npm:@atproto/jwk@0.1.4": "0.1.4",
41
+
"npm:@atproto/lex-cli@*": "0.8.2",
41
42
"npm:@atproto/lexicon@*": "0.4.11",
42
43
"npm:@atproto/lexicon@0.4.11": "0.4.11",
43
44
"npm:@atproto/lexicon@~0.4.11": "0.4.11",
···
363
364
"multiformats@9.9.0",
364
365
"zod"
365
366
]
367
+
},
368
+
"@atproto/lex-cli@0.8.2": {
369
+
"integrity": "sha512-yNQFYBV3tBBLnVrRUtUBlx/WIF4ypMFsvOsCLjA7pHL1SyW9JbczSEAoiNtoDmPc4UXCjMtXggz0ovBG8lynNA==",
370
+
"dependencies": [
371
+
"@atproto/lexicon",
372
+
"@atproto/syntax",
373
+
"chalk",
374
+
"commander@9.5.0",
375
+
"prettier",
376
+
"ts-morph",
377
+
"yesno",
378
+
"zod"
379
+
],
380
+
"bin": true
366
381
},
367
382
"@atproto/lexicon@0.4.11": {
368
383
"integrity": "sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==",
···
881
896
],
882
897
"scripts": true
883
898
},
899
+
"@ts-morph/common@0.25.0": {
900
+
"integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==",
901
+
"dependencies": [
902
+
"minimatch",
903
+
"path-browserify",
904
+
"tinyglobby"
905
+
]
906
+
},
884
907
"@tybys/wasm-util@0.9.0": {
885
908
"integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==",
886
909
"dependencies": [
···
910
933
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
911
934
"bin": true
912
935
},
936
+
"ansi-styles@4.3.0": {
937
+
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
938
+
"dependencies": [
939
+
"color-convert"
940
+
]
941
+
},
913
942
"array-flatten@1.1.1": {
914
943
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
915
944
},
···
918
947
},
919
948
"await-lock@2.2.2": {
920
949
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
950
+
},
951
+
"balanced-match@1.0.2": {
952
+
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
921
953
},
922
954
"base64-js@1.5.1": {
923
955
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
···
939
971
"unpipe"
940
972
]
941
973
},
974
+
"brace-expansion@2.0.2": {
975
+
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
976
+
"dependencies": [
977
+
"balanced-match"
978
+
]
979
+
},
942
980
"braces@3.0.3": {
943
981
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
944
982
"dependencies": [
···
998
1036
"integrity": "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==",
999
1037
"bin": true
1000
1038
},
1039
+
"chalk@4.1.2": {
1040
+
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
1041
+
"dependencies": [
1042
+
"ansi-styles",
1043
+
"supports-color"
1044
+
]
1045
+
},
1001
1046
"chownr@3.0.0": {
1002
1047
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
1003
1048
},
1004
1049
"clsx@2.1.1": {
1005
1050
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
1051
+
},
1052
+
"code-block-writer@13.0.3": {
1053
+
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="
1054
+
},
1055
+
"color-convert@2.0.1": {
1056
+
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
1057
+
"dependencies": [
1058
+
"color-name"
1059
+
]
1060
+
},
1061
+
"color-name@1.1.4": {
1062
+
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
1006
1063
},
1007
1064
"commander@2.20.3": {
1008
1065
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
···
1010
1067
"commander@8.3.0": {
1011
1068
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="
1012
1069
},
1070
+
"commander@9.5.0": {
1071
+
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="
1072
+
},
1013
1073
"content-disposition@0.5.4": {
1014
1074
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
1015
1075
"dependencies": [
···
1203
1263
"fast-redact@3.5.0": {
1204
1264
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="
1205
1265
},
1266
+
"fdir@6.4.6_picomatch@4.0.2": {
1267
+
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
1268
+
"dependencies": [
1269
+
"picomatch@4.0.2"
1270
+
],
1271
+
"optionalPeers": [
1272
+
"picomatch@4.0.2"
1273
+
]
1274
+
},
1206
1275
"fill-range@7.1.1": {
1207
1276
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
1208
1277
"dependencies": [
···
1270
1339
"graphemer@1.4.0": {
1271
1340
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
1272
1341
},
1342
+
"has-flag@4.0.0": {
1343
+
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
1344
+
},
1273
1345
"has-symbols@1.1.0": {
1274
1346
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
1275
1347
},
···
1485
1557
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
1486
1558
"dependencies": [
1487
1559
"braces",
1488
-
"picomatch"
1560
+
"picomatch@2.3.1"
1489
1561
]
1490
1562
},
1491
1563
"mime-db@1.52.0": {
···
1500
1572
"mime@1.6.0": {
1501
1573
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
1502
1574
"bin": true
1575
+
},
1576
+
"minimatch@9.0.5": {
1577
+
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
1578
+
"dependencies": [
1579
+
"brace-expansion"
1580
+
]
1503
1581
},
1504
1582
"minipass@7.1.2": {
1505
1583
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
···
1564
1642
"parseurl@1.3.3": {
1565
1643
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
1566
1644
},
1645
+
"path-browserify@1.0.1": {
1646
+
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
1647
+
},
1567
1648
"path-to-regexp@0.1.12": {
1568
1649
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
1569
1650
},
···
1573
1654
"picomatch@2.3.1": {
1574
1655
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
1575
1656
},
1657
+
"picomatch@4.0.2": {
1658
+
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="
1659
+
},
1576
1660
"pino-abstract-transport@1.2.0": {
1577
1661
"integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==",
1578
1662
"dependencies": [
···
1625
1709
},
1626
1710
"preact@10.26.9": {
1627
1711
"integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA=="
1712
+
},
1713
+
"prettier@3.5.3": {
1714
+
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
1715
+
"bin": true
1628
1716
},
1629
1717
"prismjs@1.30.0": {
1630
1718
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="
···
1806
1894
"tslib@2.4.0"
1807
1895
]
1808
1896
},
1897
+
"supports-color@7.2.0": {
1898
+
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
1899
+
"dependencies": [
1900
+
"has-flag"
1901
+
]
1902
+
},
1809
1903
"tailwind-merge@3.3.1": {
1810
1904
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="
1811
1905
},
···
1842
1936
"real-require"
1843
1937
]
1844
1938
},
1939
+
"tinyglobby@0.2.14_picomatch@4.0.2": {
1940
+
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
1941
+
"dependencies": [
1942
+
"fdir",
1943
+
"picomatch@4.0.2"
1944
+
]
1945
+
},
1845
1946
"tlds@1.259.0": {
1846
1947
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
1847
1948
"bin": true
···
1854
1955
},
1855
1956
"toidentifier@1.0.1": {
1856
1957
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
1958
+
},
1959
+
"ts-morph@24.0.0": {
1960
+
"integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==",
1961
+
"dependencies": [
1962
+
"@ts-morph/common",
1963
+
"code-block-writer"
1964
+
]
1857
1965
},
1858
1966
"tslib@2.4.0": {
1859
1967
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
···
1903
2011
},
1904
2012
"yallist@5.0.0": {
1905
2013
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="
2014
+
},
2015
+
"yesno@0.4.0": {
2016
+
"integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA=="
1906
2017
},
1907
2018
"zod@3.25.61": {
1908
2019
"integrity": "sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ=="
+39
-19
src/components/FavoriteButton.tsx
+39
-19
src/components/FavoriteButton.tsx
···
1
-
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
1
+
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
2
2
import { AtUri } from "@atproto/syntax";
3
-
import { WithBffMeta } from "@bigmoves/bff";
4
3
import { cn } from "@bigmoves/bff/components";
5
4
import { Button } from "./Button.tsx";
6
5
6
+
export type ButtonVariant = "button" | "icon-button";
7
+
7
8
export function FavoriteButton({
8
-
currentUserDid,
9
-
favs = [],
10
-
galleryUri,
9
+
class: classProp,
10
+
variant,
11
+
gallery,
11
12
}: Readonly<{
12
-
currentUserDid?: string;
13
-
favs: WithBffMeta<Favorite>[];
14
-
galleryUri: string;
13
+
class?: string;
14
+
variant?: "button" | "icon-button";
15
+
gallery: GalleryView;
15
16
}>) {
16
-
const isCreator = currentUserDid === new AtUri(galleryUri).hostname;
17
-
const favUri = favs.find((s) => currentUserDid === s.did)?.uri;
17
+
const variantClass = variant === "icon-button"
18
+
? "flex w-fit items-center gap-2 m-0 p-0 mt-2"
19
+
: undefined;
20
+
const galleryRkey = new AtUri(gallery.uri).rkey;
21
+
const favRrkey = gallery.viewer?.fav
22
+
? new AtUri(gallery.viewer.fav).rkey
23
+
: undefined;
18
24
return (
19
25
<Button
20
-
variant="primary"
26
+
variant={variant === "icon-button" ? "ghost" : "primary"}
21
27
class={cn(
22
28
"self-start w-full sm:w-fit whitespace-nowrap",
23
-
isCreator &&
24
-
"bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 text-zinc-950 dark:text-zinc-50",
29
+
variant === "icon-button" && gallery.viewer?.fav
30
+
? "text-pink-500"
31
+
: undefined,
32
+
variantClass,
33
+
classProp,
25
34
)}
26
35
type="button"
27
-
hx-post={`/actions/favorite?galleryUri=${galleryUri}${
28
-
favUri ? "&favUri=" + favUri : ""
29
-
}`}
36
+
{...gallery.viewer?.fav
37
+
? {
38
+
"hx-delete":
39
+
`/actions/${gallery.creator.did}/gallery/${galleryRkey}/favorite/${favRrkey}?variant=${variant}`,
40
+
}
41
+
: {
42
+
"hx-post":
43
+
`/actions/${gallery.creator.did}/gallery/${galleryRkey}/favorite?variant=${variant}`,
44
+
}}
30
45
hx-target="this"
31
46
hx-swap="outerHTML"
32
-
disabled={isCreator}
33
47
>
34
48
<i
35
-
class={cn("fa-heart", favUri || isCreator ? "fa-solid" : "fa-regular")}
49
+
class={cn(
50
+
"fa-heart",
51
+
variant === "icon-button" && gallery.viewer?.fav
52
+
? "text-pink-500"
53
+
: undefined,
54
+
gallery.viewer?.fav ? "fa-solid" : "fa-regular",
55
+
)}
36
56
>
37
57
</i>{" "}
38
-
{favs.length}
58
+
{gallery.favCount}
39
59
</Button>
40
60
);
41
61
}
+2
-14
src/components/GalleryPage.tsx
+2
-14
src/components/GalleryPage.tsx
···
1
-
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
2
1
import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts";
3
2
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
4
3
import { AtUri } from "@atproto/syntax";
5
-
import { WithBffMeta } from "@bigmoves/bff";
6
4
import { ModerationDecsion } from "../lib/moderation.ts";
7
5
import { EditGalleryButton } from "./EditGalleryDialog.tsx";
8
6
import { FavoriteButton } from "./FavoriteButton.tsx";
···
13
11
14
12
export function GalleryPage({
15
13
gallery,
16
-
favs = [],
17
14
currentUserDid,
18
15
modDecision,
19
16
}: Readonly<{
20
17
gallery: GalleryView;
21
-
favs: WithBffMeta<Favorite>[];
22
18
currentUserDid?: string;
23
19
modDecision?: ModerationDecsion;
24
20
}>) {
···
35
31
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row sm:flex-wrap sm:justify-end">
36
32
<EditGalleryButton gallery={gallery} />
37
33
<ShareGalleryButton gallery={gallery} />
38
-
<FavoriteButton
39
-
currentUserDid={currentUserDid}
40
-
favs={favs}
41
-
galleryUri={gallery.uri}
42
-
/>
34
+
<FavoriteButton gallery={gallery} />
43
35
</div>
44
36
)
45
37
: null}
···
47
39
? (
48
40
<div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row">
49
41
<ShareGalleryButton gallery={gallery} />
50
-
<FavoriteButton
51
-
currentUserDid={currentUserDid}
52
-
favs={favs}
53
-
galleryUri={gallery.uri}
54
-
/>
42
+
<FavoriteButton gallery={gallery} />
55
43
</div>
56
44
)
57
45
: null}
+1
-1
src/components/ModerationWrapper.tsx
+1
-1
src/components/ModerationWrapper.tsx
···
41
41
data-state="closed"
42
42
class={classProp}
43
43
>
44
-
<div class="bg-zinc-200 dark:bg-zinc-800 p-4 w-full">
44
+
<div class="bg-zinc-200 dark:bg-zinc-800 p-4 w-full rounded-md">
45
45
<div class="flex items-center justify-between gap-2 w-full">
46
46
<div class="flex items-center gap-2">
47
47
<i class="fa fa-circle-info text-zinc-500"></i>
+35
-25
src/components/TimelineItem.tsx
+35
-25
src/components/TimelineItem.tsx
···
1
1
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
2
2
import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts";
3
-
import { AtUri } from "@atproto/syntax";
4
3
import { type TimelineItem } from "../lib/timeline.ts";
5
-
import { formatRelativeTime, galleryLink } from "../utils.ts";
4
+
import { formatRelativeTime } from "../utils.ts";
6
5
import { ActorInfo } from "./ActorInfo.tsx";
6
+
import { FavoriteButton } from "./FavoriteButton.tsx";
7
7
import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx";
8
8
import { ModerationWrapper } from "./ModerationWrapper.tsx";
9
9
10
10
export function TimelineItem({ item }: Readonly<{ item: TimelineItem }>) {
11
+
const title = (item.gallery.record as Gallery).title;
12
+
const description = (item.gallery.record as Gallery).description;
11
13
return (
12
14
<li>
13
-
<div class="flex flex-col gap-4 pb-4 max-w-md">
14
-
<div class="flex items-center justify-between gap-2 w-full">
15
+
<div class="flex flex-col pb-4 max-w-md">
16
+
<div class="flex items-center justify-between gap-2 w-full mb-4">
15
17
<ActorInfo profile={item.actor} />
16
18
<span class="shrink-0">
17
19
{formatRelativeTime(new Date(item.createdAt))}
18
20
</span>
19
21
</div>
20
-
{item.gallery.items?.filter(isPhotoView).length
22
+
{item.gallery.items?.length && item.gallery.items?.length > 0
21
23
? (
22
-
<ModerationWrapper
23
-
moderationDecision={item.modDecision}
24
-
class="gap-2 sm:min-w-md"
25
-
>
26
-
<GalleryPreviewLink
27
-
gallery={item.gallery}
28
-
/>
29
-
</ModerationWrapper>
24
+
<div class="mb-4">
25
+
{item.gallery.items?.filter(isPhotoView).length
26
+
? (
27
+
<ModerationWrapper
28
+
moderationDecision={item.modDecision}
29
+
class="gap-2 sm:min-w-md"
30
+
>
31
+
<GalleryPreviewLink
32
+
gallery={item.gallery}
33
+
/>
34
+
</ModerationWrapper>
35
+
)
36
+
: null}
37
+
</div>
30
38
)
31
39
: null}
32
-
<p class="w-full flex items-baseline gap-1">
33
-
Created{" "}
34
-
<a
35
-
href={galleryLink(
36
-
item.gallery.creator.handle,
37
-
new AtUri(item.gallery.uri).rkey,
38
-
)}
39
-
class="inline-block truncate max-w-[200px] overflow-hidden font-semibold hover:underline"
40
-
>
41
-
{(item.gallery.record as Gallery).title}
42
-
</a>
43
-
</p>
40
+
{title && (
41
+
<p class="font-semibold">
42
+
{title}
43
+
</p>
44
+
)}
45
+
{description && (
46
+
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-500">
47
+
{description}
48
+
</p>
49
+
)}
50
+
<FavoriteButton
51
+
gallery={item.gallery}
52
+
variant="icon-button"
53
+
/>
44
54
</div>
45
55
</li>
46
56
);
+10
-10
src/lib/actor.ts
+10
-10
src/lib/actor.ts
···
100
100
if (!creator) return [];
101
101
102
102
return galleries.map((gallery) =>
103
-
galleryToView(
104
-
gallery,
103
+
galleryToView({
104
+
record: gallery,
105
105
creator,
106
-
galleryPhotosMap.get(gallery.uri) ?? [],
107
-
labelMap.get(gallery.uri) ?? [],
108
-
)
106
+
items: galleryPhotosMap.get(gallery.uri) ?? [],
107
+
labels: labelMap.get(gallery.uri) ?? [],
108
+
})
109
109
);
110
110
}
111
111
···
178
178
if (!gallery) return null;
179
179
const creator = creators.get(gallery.did);
180
180
if (!creator) return null;
181
-
return galleryToView(
182
-
gallery,
181
+
return galleryToView({
182
+
record: gallery,
183
183
creator,
184
-
galleryPhotosMap.get(gallery.uri) ?? [],
185
-
labelMap.get(gallery.uri) ?? [],
186
-
);
184
+
items: galleryPhotosMap.get(gallery.uri) ?? [],
185
+
labels: labelMap.get(gallery.uri) ?? [],
186
+
});
187
187
})
188
188
.filter((g) => g !== null);
189
189
}
+37
-17
src/lib/gallery.ts
+37
-17
src/lib/gallery.ts
···
2
2
import { ProfileView } from "$lexicon/types/social/grain/actor/defs.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
+
import {
6
+
GalleryView,
7
+
ViewerState,
8
+
} from "$lexicon/types/social/grain/gallery/defs.ts";
6
9
import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts";
7
10
import {
8
11
isRecord as isPhoto,
···
107
110
const labels = ctx.indexService.queryLabels({
108
111
subjects: [gallery.uri],
109
112
});
110
-
return galleryToView(
111
-
gallery,
112
-
profile,
113
-
galleryPhotosMap.get(gallery.uri) ?? [],
113
+
const favs = getGalleryFavs(gallery.uri, ctx);
114
+
const viewerFav = favs.find((fav) => fav.did === ctx.currentUser?.did);
115
+
return galleryToView({
116
+
record: gallery,
117
+
creator: profile,
118
+
items: galleryPhotosMap.get(gallery.uri) ?? [],
114
119
labels,
115
-
);
120
+
favCount: favs.length,
121
+
viewerState: {
122
+
fav: viewerFav ? viewerFav.uri : undefined,
123
+
},
124
+
});
116
125
}
117
126
118
127
export async function deleteGallery(uri: string, ctx: BffContext) {
···
152
161
return results.items;
153
162
}
154
163
155
-
export function galleryToView(
156
-
record: WithBffMeta<Gallery>,
157
-
creator: Un$Typed<ProfileView>,
158
-
items: PhotoWithExif[],
159
-
labels: Label[] = [],
160
-
): Un$Typed<GalleryView> {
164
+
export function galleryToView({
165
+
record,
166
+
creator,
167
+
items,
168
+
labels = [],
169
+
favCount,
170
+
viewerState,
171
+
}: {
172
+
record: WithBffMeta<Gallery>;
173
+
creator: Un$Typed<ProfileView>;
174
+
items: PhotoWithExif[];
175
+
labels: Label[];
176
+
favCount?: number;
177
+
viewerState?: ViewerState;
178
+
}): Un$Typed<GalleryView> {
161
179
return {
162
180
uri: record.uri,
163
181
cid: record.cid,
···
168
186
.filter(isPhotoView),
169
187
labels,
170
188
indexedAt: record.indexedAt,
189
+
favCount,
190
+
viewer: viewerState,
171
191
};
172
192
}
173
193
···
227
247
const labels = ctx.indexService.queryLabels({ subjects: uris });
228
248
229
249
return galleries.map((gallery) =>
230
-
galleryToView(
231
-
gallery,
232
-
profile,
233
-
galleryPhotosMap.get(gallery.uri) ?? [],
250
+
galleryToView({
251
+
record: gallery,
252
+
creator: profile,
253
+
items: galleryPhotosMap.get(gallery.uri) ?? [],
234
254
labels,
235
-
)
255
+
})
236
256
);
237
257
}
238
258
+6
-6
src/lib/photo.ts
+6
-6
src/lib/photo.ts
···
234
234
.map((gallery) => {
235
235
const profile = getActorProfile(gallery.did, ctx);
236
236
if (!profile) return undefined;
237
-
return galleryToView(
238
-
gallery,
239
-
profile,
240
-
galleryPhotosMap.get(gallery.uri) ?? [],
241
-
labels ?? [],
242
-
);
237
+
return galleryToView({
238
+
record: gallery,
239
+
creator: profile,
240
+
items: galleryPhotosMap.get(gallery.uri) ?? [],
241
+
labels: labels ?? [],
242
+
});
243
243
})
244
244
.filter((g): g is GalleryView => Boolean(g));
245
245
}
+17
-2
src/lib/timeline.ts
+17
-2
src/lib/timeline.ts
···
8
8
import { AtUri } from "@atproto/syntax";
9
9
import { BffContext, QueryOptions, WithBffMeta } from "@bigmoves/bff";
10
10
import { getActorProfile } from "./actor.ts";
11
-
import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts";
11
+
import {
12
+
galleryToView,
13
+
getGalleryFavs,
14
+
getGalleryItemsAndPhotos,
15
+
} from "./gallery.ts";
12
16
import { moderateGallery, ModerationDecsion } from "./moderation.ts";
13
17
14
18
export type TimelineItemType = "gallery";
···
75
79
const labels = ctx.indexService.queryLabels({
76
80
subjects: [gallery.uri],
77
81
});
78
-
const galleryView = galleryToView(gallery, profile, galleryPhotos, labels);
82
+
const favs = getGalleryFavs(gallery.uri, ctx);
83
+
const viewerFav = favs.find((fav) => fav.did === ctx.currentUser?.did);
84
+
const galleryView = galleryToView({
85
+
record: gallery,
86
+
creator: profile,
87
+
items: galleryPhotos,
88
+
labels,
89
+
favCount: favs.length,
90
+
viewerState: {
91
+
fav: viewerFav ? viewerFav.uri : undefined,
92
+
},
93
+
});
79
94
80
95
let modDecision: ModerationDecsion | undefined = undefined;
81
96
if (galleryView.labels?.length) {
+10
-1
src/main.tsx
+10
-1
src/main.tsx
···
105
105
route("/actions/photo/:rkey", ["PUT"], actions.photoEdit),
106
106
route("/actions/photo/:rkey", ["DELETE"], actions.photoDelete),
107
107
route("/actions/photo", ["POST"], actions.uploadPhoto),
108
-
route("/actions/favorite", ["POST"], actions.galleryFavorite),
108
+
route(
109
+
"/actions/:creatorDid/gallery/:rkey/favorite",
110
+
["POST"],
111
+
actions.galleryFavorite,
112
+
),
113
+
route(
114
+
"/actions/:creatorDid/gallery/:rkey/favorite/:favRkey",
115
+
["DELETE"],
116
+
actions.galleryUnfavorite,
117
+
),
109
118
route("/actions/profile", ["PUT"], actions.profileUpdate),
110
119
route("/actions/gallery/:rkey/sort", ["POST"], actions.gallerySort),
111
120
route("/actions/get-blob", ["GET"], actions.getBlob),
+49
-26
src/routes/actions.tsx
+49
-26
src/routes/actions.tsx
···
9
9
import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts";
10
10
import { AtUri } from "@atproto/syntax";
11
11
import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff";
12
-
import { FavoriteButton } from "../components/FavoriteButton.tsx";
12
+
import {
13
+
ButtonVariant,
14
+
FavoriteButton,
15
+
} from "../components/FavoriteButton.tsx";
13
16
import { FollowButton } from "../components/FollowButton.tsx";
14
17
import { GalleryEditPhotosDialog } from "../components/GalleryEditPhotosDialog.tsx";
15
18
import { GalleryInfo } from "../components/GalleryInfo.tsx";
···
18
21
import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx";
19
22
import { getActorPhotos } from "../lib/actor.ts";
20
23
import { getFollowers } from "../lib/follow.ts";
21
-
import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts";
24
+
import { deleteGallery, getGallery } from "../lib/gallery.ts";
22
25
import { getPhoto, photoToView } from "../lib/photo.ts";
23
26
import type { State } from "../state.ts";
24
27
import { galleryLink, profileLink, uploadPageLink } from "../utils.ts";
···
393
396
394
397
export const galleryFavorite: RouteHandler = async (
395
398
req,
396
-
_params,
399
+
params,
400
+
ctx: BffContext<State>,
401
+
) => {
402
+
ctx.requireAuth();
403
+
const url = new URL(req.url);
404
+
const variant = url.searchParams.get("variant") as ButtonVariant || "button";
405
+
const creatorDid = params.creatorDid;
406
+
const galleryRkey = params.rkey;
407
+
const galleryUri = `at://${creatorDid}/social.grain.gallery/${galleryRkey}`;
408
+
409
+
try {
410
+
await ctx.createRecord<WithBffMeta<Favorite>>("social.grain.favorite", {
411
+
subject: galleryUri,
412
+
createdAt: new Date().toISOString(),
413
+
});
414
+
} catch (e) {
415
+
console.error("Error creating favorite record:", e);
416
+
}
417
+
418
+
const gallery = getGallery(creatorDid, galleryRkey, ctx);
419
+
if (!gallery) return ctx.next();
420
+
421
+
return ctx.html(
422
+
<FavoriteButton gallery={gallery} variant={variant} />,
423
+
);
424
+
};
425
+
426
+
export const galleryUnfavorite: RouteHandler = async (
427
+
req,
428
+
params,
397
429
ctx: BffContext<State>,
398
430
) => {
399
431
const { did } = ctx.requireAuth();
400
432
const url = new URL(req.url);
401
-
const searchParams = new URLSearchParams(url.search);
402
-
const galleryUri = searchParams.get("galleryUri");
403
-
const favUri = searchParams.get("favUri") ?? undefined;
404
-
if (!galleryUri) return ctx.next();
405
-
if (favUri) {
433
+
const variant = url.searchParams.get("variant") as ButtonVariant || "button";
434
+
const creatorDid = params.creatorDid;
435
+
const galleryRkey = params.rkey;
436
+
const favRkey = params.favRkey;
437
+
const favUri = `at://${did}/social.grain.favorite/${favRkey}`;
438
+
439
+
try {
406
440
await ctx.deleteRecord(favUri);
407
-
const favs = getGalleryFavs(galleryUri, ctx);
408
-
return ctx.html(
409
-
<FavoriteButton
410
-
currentUserDid={did}
411
-
favs={favs}
412
-
galleryUri={galleryUri}
413
-
/>,
414
-
);
441
+
} catch (e) {
442
+
console.error("Error deleting favorite record:", e);
415
443
}
416
-
await ctx.createRecord<WithBffMeta<Favorite>>("social.grain.favorite", {
417
-
subject: galleryUri,
418
-
createdAt: new Date().toISOString(),
419
-
});
420
-
const favs = getGalleryFavs(galleryUri, ctx);
444
+
445
+
const gallery = getGallery(creatorDid, galleryRkey, ctx);
446
+
if (!gallery) return ctx.next();
447
+
421
448
return ctx.html(
422
-
<FavoriteButton
423
-
currentUserDid={did}
424
-
galleryUri={galleryUri}
425
-
favs={favs}
426
-
/>,
449
+
<FavoriteButton gallery={gallery} variant={variant} />,
427
450
);
428
451
};
429
452
+2
-7
src/routes/gallery.tsx
+2
-7
src/routes/gallery.tsx
···
1
-
import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
2
1
import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
3
-
import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff";
2
+
import { BffContext, RouteHandler } from "@bigmoves/bff";
4
3
import { GalleryPage } from "../components/GalleryPage.tsx";
5
-
import { getGallery, getGalleryFavs } from "../lib/gallery.ts";
4
+
import { getGallery } from "../lib/gallery.ts";
6
5
import { moderateGallery, ModerationDecsion } from "../lib/moderation.ts";
7
6
import { getGalleryMeta, getPageMeta } from "../meta.ts";
8
7
import type { State } from "../state.ts";
···
14
13
ctx: BffContext<State>,
15
14
) => {
16
15
const did = ctx.currentUser?.did;
17
-
let favs: WithBffMeta<Favorite>[] = [];
18
16
const handle = params.handle;
19
17
const rkey = params.rkey;
20
18
const gallery = getGallery(handle, rkey, ctx);
21
19
22
20
if (!gallery) return ctx.next();
23
-
24
-
favs = getGalleryFavs(gallery.uri, ctx);
25
21
26
22
ctx.state.meta = [
27
23
{ title: `${(gallery.record as Gallery).title} — Grain` },
···
36
32
37
33
return ctx.render(
38
34
<GalleryPage
39
-
favs={favs}
40
35
gallery={gallery}
41
36
currentUserDid={did}
42
37
modDecision={modDecision}