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

Timeline/favs update (#4)

* add viewer state to gallery view with fav and fav count, update timeline to include favs, refactor fav actions

* tidy

authored by chadtmiller.com and committed by GitHub 2a913c96 cbd9078d

Changed files
+368 -131
__generated__
types
social
grain
gallery
lexicons
social
grain
gallery
src
+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',
+18
__generated__/types/social/grain/gallery/defs.ts
··· 24 24 creator: SocialGrainActorDefs.ProfileView 25 25 record: { [_ in string]: unknown } 26 26 items?: ($Typed<SocialGrainPhotoDefs.PhotoView> | { $type: string })[] 27 + favCount?: number 27 28 labels?: ComAtprotoLabelDefs.Label[] 28 29 indexedAt: string 30 + viewer?: ViewerState 29 31 } 30 32 31 33 const hashGalleryView = 'galleryView' ··· 37 39 export function validateGalleryView<V>(v: V) { 38 40 return validate<GalleryView & V>(v, id, hashGalleryView) 39 41 } 42 + 43 + /** Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. */ 44 + export interface ViewerState { 45 + $type?: 'social.grain.gallery.defs#viewerState' 46 + fav?: string 47 + } 48 + 49 + const hashViewerState = 'viewerState' 50 + 51 + export function isViewerState<V>(v: V) { 52 + return is$typed(v, id, hashViewerState) 53 + } 54 + 55 + export function validateViewerState<V>(v: V) { 56 + return validate<ViewerState & V>(v, id, hashViewerState) 57 + }
+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=="
+10 -1
lexicons/social/grain/gallery/defs.json
··· 22 22 ] 23 23 } 24 24 }, 25 + "favCount": { "type": "integer" }, 25 26 "labels": { 26 27 "type": "array", 27 28 "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 28 29 }, 29 - "indexedAt": { "type": "string", "format": "datetime" } 30 + "indexedAt": { "type": "string", "format": "datetime" }, 31 + "viewer": { "type": "ref", "ref": "#viewerState" } 32 + } 33 + }, 34 + "viewerState": { 35 + "type": "object", 36 + "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", 37 + "properties": { 38 + "fav": { "type": "string", "format": "at-uri" } 30 39 } 31 40 } 32 41 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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}