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

Compare changes

Choose any two refs to compare.

Changed files
+900 -227
__generated__
types
social
grain
gallery
lexicons
social
grain
gallery
static
+4
__generated__/lexicons.ts
··· 2341 2341 type: 'string', 2342 2342 format: 'at-uri', 2343 2343 }, 2344 + position: { 2345 + type: 'integer', 2346 + default: 0, 2347 + }, 2344 2348 }, 2345 2349 }, 2346 2350 },
+1
__generated__/types/social/grain/gallery/item.ts
··· 19 19 createdAt: string 20 20 gallery: string 21 21 item: string 22 + position: number 22 23 [k: string]: unknown 23 24 } 24 25
+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.11", 5 + "@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.15", 6 6 "@gfx/canvas": "jsr:@gfx/canvas@^0.5.8", 7 7 "@std/path": "jsr:@std/path@^1.0.9", 8 8 "@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+223 -21
deno.lock
··· 1 1 { 2 2 "version": "4", 3 3 "specifiers": { 4 - "jsr:@bigmoves/atproto-oauth-client@0.1": "0.1.0", 5 - "jsr:@bigmoves/bff@0.3.0-beta.11": "0.3.0-beta.11", 4 + "jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0", 5 + "jsr:@bigmoves/bff@0.3.0-beta.15": "0.3.0-beta.15", 6 + "jsr:@deno/gfm@0.10": "0.10.0", 7 + "jsr:@denosaurs/emoji@0.3": "0.3.1", 6 8 "jsr:@denosaurs/plug@1": "1.0.5", 7 9 "jsr:@denosaurs/plug@1.0.5": "1.0.5", 8 10 "jsr:@gfx/canvas@~0.5.8": "0.5.8", 9 11 "jsr:@std/assert@0.214": "0.214.0", 10 12 "jsr:@std/assert@0.217": "0.217.0", 13 + "jsr:@std/assert@^1.0.12": "1.0.13", 11 14 "jsr:@std/assert@^1.0.13": "1.0.13", 15 + "jsr:@std/async@^1.0.12": "1.0.12", 12 16 "jsr:@std/cache@0.2": "0.2.0", 13 17 "jsr:@std/cli@^1.0.17": "1.0.17", 18 + "jsr:@std/data-structures@^1.0.6": "1.0.7", 14 19 "jsr:@std/encoding@0.214": "0.214.0", 15 20 "jsr:@std/encoding@0.217.0": "0.217.0", 16 21 "jsr:@std/encoding@^1.0.10": "1.0.10", 17 22 "jsr:@std/fmt@0.214": "0.214.0", 18 - "jsr:@std/fmt@^1.0.7": "1.0.7", 23 + "jsr:@std/fmt@^1.0.8": "1.0.8", 19 24 "jsr:@std/fs@0.214": "0.214.0", 20 25 "jsr:@std/fs@0.217.0": "0.217.0", 21 - "jsr:@std/html@^1.0.3": "1.0.3", 22 - "jsr:@std/http@^1.0.13": "1.0.15", 23 - "jsr:@std/internal@^1.0.6": "1.0.6", 26 + "jsr:@std/fs@^1.0.15": "1.0.17", 27 + "jsr:@std/fs@^1.0.16": "1.0.17", 28 + "jsr:@std/html@^1.0.4": "1.0.4", 29 + "jsr:@std/http@^1.0.13": "1.0.16", 30 + "jsr:@std/internal@^1.0.6": "1.0.7", 24 31 "jsr:@std/media-types@^1.1.0": "1.1.0", 25 32 "jsr:@std/net@^1.0.4": "1.0.4", 26 33 "jsr:@std/path@0.214": "0.214.0", ··· 29 36 "jsr:@std/path@^1.0.8": "1.0.9", 30 37 "jsr:@std/path@^1.0.9": "1.0.9", 31 38 "jsr:@std/streams@^1.0.9": "1.0.9", 39 + "jsr:@std/testing@^1.0.11": "1.0.11", 32 40 "npm:@atproto-labs/handle-resolver-node@~0.1.14": "0.1.14", 33 41 "npm:@atproto-labs/simple-store@~0.1.2": "0.1.2", 34 42 "npm:@atproto/api@~0.14.19": "0.14.22", ··· 42 50 "npm:@atproto/oauth-types@~0.2.4": "0.2.4", 43 51 "npm:@atproto/syntax@0.4": "0.4.0", 44 52 "npm:@atproto/xrpc-server@*": "0.7.15", 53 + "npm:@skyware/jetstream@~0.2.2": "0.2.2", 45 54 "npm:@tailwindcss/cli@*": "4.1.4", 55 + "npm:@tailwindcss/cli@^4.0.12": "4.1.4", 56 + "npm:@tailwindcss/cli@^4.1.3": "4.1.4", 46 57 "npm:@tailwindcss/cli@^4.1.4": "4.1.4", 47 58 "npm:@types/node@*": "22.12.0", 48 59 "npm:clsx@^2.1.1": "2.1.1", 49 60 "npm:date-fns@^4.1.0": "4.1.0", 61 + "npm:github-slugger@2": "2.0.0", 62 + "npm:he@^1.2.0": "1.2.0", 50 63 "npm:jose@5.9.6": "5.9.6", 64 + "npm:katex@0.16": "0.16.22", 65 + "npm:marked-alert@2": "2.1.2_marked@12.0.2", 66 + "npm:marked-footnote@^1.2.0": "1.2.4_marked@12.0.2", 67 + "npm:marked-gfm-heading-id@^3.1.0": "3.2.0_marked@12.0.2", 68 + "npm:marked@12": "12.0.2", 51 69 "npm:multiformats@*": "9.9.0", 52 70 "npm:multiformats@^13.3.2": "13.3.2", 53 71 "npm:popmotion@^11.0.5": "11.0.5", 54 72 "npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.26.5", 55 73 "npm:preact@^10.26.5": "10.26.5", 74 + "npm:prismjs@^1.29.0": "1.30.0", 75 + "npm:sanitize-html@^2.13.0": "2.15.0", 56 76 "npm:sharp@~0.34.1": "0.34.1", 57 77 "npm:tailwind-merge@^3.2.0": "3.2.0", 78 + "npm:tailwindcss@^4.0.12": "4.1.4", 79 + "npm:tailwindcss@^4.1.3": "4.1.4", 58 80 "npm:tailwindcss@^4.1.4": "4.1.4", 59 81 "npm:typed-htmx@~0.3.1": "0.3.1" 60 82 }, 61 83 "jsr": { 62 - "@bigmoves/atproto-oauth-client@0.1.0": { 63 - "integrity": "d5858f534a800a46af28b1c03b447b179d15bbf164c24767601ae78513501711", 84 + "@bigmoves/atproto-oauth-client@0.2.0": { 85 + "integrity": "5c3ca124dd52eff51dace83790779ebe48c4b41559b799e16c8750bd415f2124", 64 86 "dependencies": [ 65 87 "npm:@atproto-labs/handle-resolver-node", 66 88 "npm:@atproto-labs/simple-store", ··· 70 92 "npm:jose" 71 93 ] 72 94 }, 73 - "@bigmoves/bff@0.3.0-beta.11": { 74 - "integrity": "1bcdf36eaa440d2cafbf834b37852b4b3f49c97d9802b2307d077cb2f507db5f", 95 + "@bigmoves/bff@0.3.0-beta.15": { 96 + "integrity": "934d0fab8cc73804099ccb5362fa89f5ef3cd6269a6613029131770c97cdfcb9", 75 97 "dependencies": [ 76 98 "jsr:@bigmoves/atproto-oauth-client", 77 99 "jsr:@std/assert@^1.0.13", ··· 91 113 "npm:tailwind-merge" 92 114 ] 93 115 }, 116 + "@deno/gfm@0.10.0": { 117 + "integrity": "51708205e3559a4aeb6afb29d07c5bfafe7941f91bb360351ef6621de9a39527", 118 + "dependencies": [ 119 + "jsr:@denosaurs/emoji", 120 + "npm:github-slugger", 121 + "npm:he", 122 + "npm:katex", 123 + "npm:marked", 124 + "npm:marked-alert", 125 + "npm:marked-footnote", 126 + "npm:marked-gfm-heading-id", 127 + "npm:prismjs", 128 + "npm:sanitize-html" 129 + ] 130 + }, 131 + "@denosaurs/emoji@0.3.1": { 132 + "integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b" 133 + }, 94 134 "@denosaurs/plug@1.0.5": { 95 135 "integrity": "04cd988da558adc226202d88c3a434d5fcc08146eaf4baf0cea0c2284b16d2bf", 96 136 "dependencies": [ ··· 122 162 "jsr:@std/internal" 123 163 ] 124 164 }, 165 + "@std/async@1.0.12": { 166 + "integrity": "d1bfcec459e8012846fe4e38dfc4241ab23240ecda3d8d6dfcf6d81a632e803d" 167 + }, 125 168 "@std/cache@0.2.0": { 126 169 "integrity": "63a2ccd5a9e7c03e430f7d34dfcfd0d0cfc90731a1eaf8208f4c66e418fc3035" 127 170 }, 128 171 "@std/cli@1.0.17": { 129 172 "integrity": "e15b9abe629e17be90cc6216327f03a29eae613365f1353837fa749aad29ce7b" 173 + }, 174 + "@std/data-structures@1.0.7": { 175 + "integrity": "16932d2c8d281f65eaaa2209af2473209881e33b1ced54cd1b015e7b4cdbb0d2" 130 176 }, 131 177 "@std/encoding@0.214.0": { 132 178 "integrity": "30a8713e1db22986c7e780555ffd2fefd1d4f9374d734bb41f5970f6c3352af5" ··· 140 186 "@std/fmt@0.214.0": { 141 187 "integrity": "40382cff88a0783b347b4d69b94cf931ab8e549a733916718cb866c08efac4d4" 142 188 }, 143 - "@std/fmt@1.0.7": { 144 - "integrity": "2a727c043d8df62cd0b819b3fb709b64dd622e42c3b1bb817ea7e6cc606360fb" 189 + "@std/fmt@1.0.8": { 190 + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" 145 191 }, 146 192 "@std/fs@0.214.0": { 147 193 "integrity": "bc880fea0be120cb1550b1ed7faf92fe071003d83f2456a1e129b39193d85bea", ··· 157 203 "jsr:@std/path@0.217" 158 204 ] 159 205 }, 160 - "@std/html@1.0.3": { 161 - "integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988" 206 + "@std/fs@1.0.17": { 207 + "integrity": "1c00c632677c1158988ef7a004cb16137f870aafdb8163b9dce86ec652f3952b", 208 + "dependencies": [ 209 + "jsr:@std/path@^1.0.9" 210 + ] 211 + }, 212 + "@std/html@1.0.4": { 213 + "integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e" 162 214 }, 163 - "@std/http@1.0.15": { 164 - "integrity": "435a4934b4e196e82a8233f724da525f7b7112f3566502f28815e94764c19159", 215 + "@std/http@1.0.16": { 216 + "integrity": "80c8d08c4bfcf615b89978dcefb84f7e880087cf3b6b901703936f3592a06933", 165 217 "dependencies": [ 166 218 "jsr:@std/cli", 167 219 "jsr:@std/encoding@^1.0.10", 168 - "jsr:@std/fmt@^1.0.7", 220 + "jsr:@std/fmt@^1.0.8", 169 221 "jsr:@std/html", 170 222 "jsr:@std/media-types", 171 223 "jsr:@std/net", ··· 173 225 "jsr:@std/streams" 174 226 ] 175 227 }, 176 - "@std/internal@1.0.6": { 177 - "integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4" 228 + "@std/internal@1.0.7": { 229 + "integrity": "39eeb5265190a7bc5d5591c9ff019490bd1f2c3907c044a11b0d545796158a0f" 178 230 }, 179 231 "@std/media-types@1.1.0": { 180 232 "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" ··· 199 251 }, 200 252 "@std/streams@1.0.9": { 201 253 "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035" 254 + }, 255 + "@std/testing@1.0.11": { 256 + "integrity": "12b3db12d34f0f385a26248933bde766c0f8c5ad8b6ab34d4d38f528ab852f48", 257 + "dependencies": [ 258 + "jsr:@std/assert@^1.0.12", 259 + "jsr:@std/async", 260 + "jsr:@std/data-structures", 261 + "jsr:@std/fs@^1.0.16", 262 + "jsr:@std/internal", 263 + "jsr:@std/path@^1.0.8" 264 + ] 202 265 } 203 266 }, 204 267 "npm": { 268 + "@atcute/bluesky@1.0.15_@atcute+client@2.0.9": { 269 + "integrity": "sha512-+EFiybmKQ97aBAgtaD+cKRJER5AMn3cZMkEwEg/pDdWyzxYJ9m1UgemmLdTgI8VrxPufKqdXS2nl7uO7TY6BPA==", 270 + "dependencies": [ 271 + "@atcute/client" 272 + ] 273 + }, 274 + "@atcute/client@2.0.9": { 275 + "integrity": "sha512-QNDm9gMP6x9LY77ArwY+urQOBtQW74/onEAz42c40JxRm6Rl9K9cU4ROvNKJ+5cpVmEm1sthEWVRmDr5CSZENA==" 276 + }, 205 277 "@atproto-labs/did-resolver@0.1.11": { 206 278 "integrity": "sha512-qXNzIX2GPQnxT1gl35nv/8ErDdc4Fj/+RlJE7oyE7JGkFAPUyuY03TvKJ79SmWFsWE8wyTXEpLuphr9Da1Vhkw==", 207 279 "dependencies": [ ··· 334 406 "@atproto/lexicon", 335 407 "@atproto/syntax", 336 408 "chalk", 337 - "commander", 409 + "commander@9.5.0", 338 410 "prettier", 339 411 "ts-morph", 340 412 "yesno", ··· 611 683 "node-addon-api" 612 684 ] 613 685 }, 686 + "@skyware/jetstream@0.2.2": { 687 + "integrity": "sha512-d1MtWPTIFEciSzV8OClXZCJoz0DJ7aupt4EZSwpGAASYG0ZIPmZTt7RVJkoFzQyqRPHAMD7CvEwu0ut3MHX1og==", 688 + "dependencies": [ 689 + "@atcute/bluesky", 690 + "partysocket" 691 + ] 692 + }, 614 693 "@tailwindcss/cli@4.1.4": { 615 694 "integrity": "sha512-gP05Qihh+cZ2FqD5fa0WJXx3KEk2YWUYv/RBKAyiOg0V4vYVDr/xlLc0sacpnVEXM45BVUR9U2hsESufYs6YTA==", 616 695 "dependencies": [ ··· 856 935 "color-convert", 857 936 "color-string" 858 937 ] 938 + }, 939 + "commander@8.3.0": { 940 + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" 859 941 }, 860 942 "commander@9.5.0": { 861 943 "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==" ··· 884 966 "ms@2.0.0" 885 967 ] 886 968 }, 969 + "deepmerge@4.3.1": { 970 + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" 971 + }, 887 972 "depd@2.0.0": { 888 973 "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 889 974 }, ··· 896 981 "detect-libc@2.0.3": { 897 982 "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==" 898 983 }, 984 + "dom-serializer@2.0.0": { 985 + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 986 + "dependencies": [ 987 + "domelementtype", 988 + "domhandler", 989 + "entities" 990 + ] 991 + }, 992 + "domelementtype@2.3.0": { 993 + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" 994 + }, 995 + "domhandler@5.0.3": { 996 + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 997 + "dependencies": [ 998 + "domelementtype" 999 + ] 1000 + }, 1001 + "domutils@3.2.2": { 1002 + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 1003 + "dependencies": [ 1004 + "dom-serializer", 1005 + "domelementtype", 1006 + "domhandler" 1007 + ] 1008 + }, 899 1009 "dunder-proto@1.0.1": { 900 1010 "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 901 1011 "dependencies": [ ··· 919 1029 "graceful-fs", 920 1030 "tapable" 921 1031 ] 1032 + }, 1033 + "entities@4.5.0": { 1034 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" 922 1035 }, 923 1036 "es-define-property@1.0.1": { 924 1037 "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" ··· 935 1048 "escape-html@1.0.3": { 936 1049 "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 937 1050 }, 1051 + "escape-string-regexp@4.0.0": { 1052 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" 1053 + }, 938 1054 "etag@1.8.1": { 939 1055 "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" 1056 + }, 1057 + "event-target-polyfill@0.0.4": { 1058 + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==" 940 1059 }, 941 1060 "event-target-shim@5.0.1": { 942 1061 "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" ··· 1044 1163 "es-object-atoms" 1045 1164 ] 1046 1165 }, 1166 + "github-slugger@2.0.0": { 1167 + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" 1168 + }, 1047 1169 "gopd@1.2.0": { 1048 1170 "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 1049 1171 }, ··· 1065 1187 "function-bind" 1066 1188 ] 1067 1189 }, 1190 + "he@1.2.0": { 1191 + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" 1192 + }, 1068 1193 "hey-listen@1.0.8": { 1069 1194 "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" 1070 1195 }, 1196 + "htmlparser2@8.0.2": { 1197 + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", 1198 + "dependencies": [ 1199 + "domelementtype", 1200 + "domhandler", 1201 + "domutils", 1202 + "entities" 1203 + ] 1204 + }, 1071 1205 "http-errors@2.0.0": { 1072 1206 "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 1073 1207 "dependencies": [ ··· 1111 1245 "is-number@7.0.0": { 1112 1246 "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" 1113 1247 }, 1248 + "is-plain-object@5.0.0": { 1249 + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" 1250 + }, 1114 1251 "iso-datestring-validator@2.2.2": { 1115 1252 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 1116 1253 }, ··· 1119 1256 }, 1120 1257 "jose@5.9.6": { 1121 1258 "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==" 1259 + }, 1260 + "katex@0.16.22": { 1261 + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", 1262 + "dependencies": [ 1263 + "commander@8.3.0" 1264 + ] 1122 1265 }, 1123 1266 "lightningcss-darwin-arm64@1.29.2": { 1124 1267 "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==" ··· 1169 1312 "lru-cache@10.4.3": { 1170 1313 "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" 1171 1314 }, 1315 + "marked-alert@2.1.2_marked@12.0.2": { 1316 + "integrity": "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==", 1317 + "dependencies": [ 1318 + "marked" 1319 + ] 1320 + }, 1321 + "marked-footnote@1.2.4_marked@12.0.2": { 1322 + "integrity": "sha512-DB2Kl+wFh6YwZd70qABMY6WUkG1UuyqoNTFoDfGyG79Pz24neYtLBkB+45a7o72V7gkfvbC3CGzIYFobxfMT1Q==", 1323 + "dependencies": [ 1324 + "marked" 1325 + ] 1326 + }, 1327 + "marked-gfm-heading-id@3.2.0_marked@12.0.2": { 1328 + "integrity": "sha512-Xfxpr5lXLDLY10XqzSCA9l2dDaiabQUgtYM9hw8yunyVsB/xYBRpiic6BOiY/EAJw1ik1eWr1ET1HKOAPZBhXg==", 1329 + "dependencies": [ 1330 + "github-slugger", 1331 + "marked" 1332 + ] 1333 + }, 1334 + "marked@12.0.2": { 1335 + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==" 1336 + }, 1172 1337 "math-intrinsics@1.1.0": { 1173 1338 "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" 1174 1339 }, ··· 1221 1386 "multiformats@9.9.0": { 1222 1387 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 1223 1388 }, 1389 + "nanoid@3.3.11": { 1390 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" 1391 + }, 1224 1392 "negotiator@0.6.3": { 1225 1393 "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" 1226 1394 }, ··· 1245 1413 "ee-first" 1246 1414 ] 1247 1415 }, 1416 + "parse-srcset@1.0.2": { 1417 + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" 1418 + }, 1248 1419 "parseurl@1.3.3": { 1249 1420 "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 1250 1421 }, 1422 + "partysocket@1.1.3": { 1423 + "integrity": "sha512-87Jd/nqPoWnVfzHE6Z12WLWTJ+TAgxs0b7i2S163HfQSrVDUK5tW/FC64T5N8L5ss+gqF+EV0BwjZMWggMY3UA==", 1424 + "dependencies": [ 1425 + "event-target-polyfill" 1426 + ] 1427 + }, 1251 1428 "path-browserify@1.0.1": { 1252 1429 "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" 1253 1430 }, ··· 1298 1475 "tslib@2.4.0" 1299 1476 ] 1300 1477 }, 1478 + "postcss@8.5.3": { 1479 + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", 1480 + "dependencies": [ 1481 + "nanoid", 1482 + "picocolors", 1483 + "source-map-js" 1484 + ] 1485 + }, 1301 1486 "preact-render-to-string@6.5.13_preact@10.26.5": { 1302 1487 "integrity": "sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==", 1303 1488 "dependencies": [ ··· 1309 1494 }, 1310 1495 "prettier@3.5.3": { 1311 1496 "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==" 1497 + }, 1498 + "prismjs@1.30.0": { 1499 + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==" 1312 1500 }, 1313 1501 "process-warning@3.0.0": { 1314 1502 "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" ··· 1378 1566 "safer-buffer@2.1.2": { 1379 1567 "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1380 1568 }, 1569 + "sanitize-html@2.15.0": { 1570 + "integrity": "sha512-wIjst57vJGpLyBP8ioUbg6ThwJie5SuSIjHxJg53v5Fg+kUK+AXlb7bK3RNXpp315MvwM+0OBGCV6h5pPHsVhA==", 1571 + "dependencies": [ 1572 + "deepmerge", 1573 + "escape-string-regexp", 1574 + "htmlparser2", 1575 + "is-plain-object", 1576 + "parse-srcset", 1577 + "postcss" 1578 + ] 1579 + }, 1381 1580 "semver@7.7.1": { 1382 1581 "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" 1383 1582 }, ··· 1486 1685 "dependencies": [ 1487 1686 "atomic-sleep" 1488 1687 ] 1688 + }, 1689 + "source-map-js@1.2.1": { 1690 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" 1489 1691 }, 1490 1692 "split2@4.2.0": { 1491 1693 "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" ··· 1608 1810 }, 1609 1811 "workspace": { 1610 1812 "dependencies": [ 1611 - "jsr:@bigmoves/bff@0.3.0-beta.11", 1813 + "jsr:@bigmoves/bff@0.3.0-beta.15", 1612 1814 "jsr:@gfx/canvas@~0.5.8", 1613 1815 "jsr:@std/path@^1.0.9", 1614 1816 "npm:@atproto/syntax@0.4",
+2 -10
input.css
··· 1 1 @import "tailwindcss"; 2 2 3 - .htmx-request.htmx-indicator { 4 - display: inline; 5 - } 6 - .htmx-indicator { 7 - display: none; 8 - } 9 - .htmx-request #submit-button { 10 - opacity: 0.5; 11 - pointer-events: none; 12 - } 3 + /* use to test light mode */ 4 + /* @custom-variant dark (&:where(.dark, .dark *)); */
+4
lexicons/social/grain/gallery/item.json
··· 20 20 "item": { 21 21 "type": "string", 22 22 "format": "at-uri" 23 + }, 24 + "position": { 25 + "type": "integer", 26 + "default": 0 23 27 } 24 28 } 25 29 }
+423 -157
main.tsx
··· 46 46 } from "@bigmoves/bff/components"; 47 47 import { createCanvas, Image } from "@gfx/canvas"; 48 48 import { join } from "@std/path"; 49 - import { formatDistanceStrict } from "date-fns"; 49 + import { 50 + differenceInDays, 51 + differenceInHours, 52 + differenceInMinutes, 53 + differenceInWeeks, 54 + } from "date-fns"; 50 55 import { wrap } from "popmotion"; 51 56 import { ComponentChildren, JSX, VNode } from "preact"; 52 57 53 58 const PUBLIC_URL = Deno.env.get("BFF_PUBLIC_URL") ?? "http://localhost:8080"; 54 59 const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 55 60 56 - let cssContentHash: string = ""; 61 + const staticFilesHash = new Map<string, string>(); 57 62 58 63 bff({ 59 64 appName: "Grain Social", ··· 68 73 lexicons, 69 74 rootElement: Root, 70 75 onListen: async () => { 71 - const cssFileContent = await Deno.readFile( 72 - join(Deno.cwd(), "static", "styles.css"), 73 - ); 74 - const hashBuffer = await crypto.subtle.digest("SHA-256", cssFileContent); 75 - cssContentHash = Array.from(new Uint8Array(hashBuffer)) 76 - .map((b) => b.toString(16).padStart(2, "0")) 77 - .join(""); 76 + for (const entry of Deno.readDirSync(join(Deno.cwd(), "static"))) { 77 + if ( 78 + entry.isFile && 79 + (entry.name.endsWith(".js") || entry.name.endsWith(".css")) 80 + ) { 81 + const fileContent = await Deno.readFile( 82 + join(Deno.cwd(), "static", entry.name), 83 + ); 84 + const hashBuffer = await crypto.subtle.digest("SHA-256", fileContent); 85 + const hash = Array.from(new Uint8Array(hashBuffer)) 86 + .map((b) => b.toString(16).padStart(2, "0")) 87 + .join(""); 88 + staticFilesHash.set(entry.name, hash); 89 + } 90 + } 78 91 }, 79 92 onError: (err) => { 80 93 if (err instanceof UnauthorizedError) { ··· 102 115 <div 103 116 id="login" 104 117 class="flex justify-center items-center w-full h-full relative" 105 - style="background-image: url('https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreiewhwu3ro5dv7omedphb62db4koa7qtvyzfhiiypg3ru4tvuxkrjy@webp'); background-size: cover; background-position: center;" 118 + style="background-image: url('https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:bcgltzqazw5tb6k2g3ttenbj/bafkreiewhwu3ro5dv7omedphb62db4koa7qtvyzfhiiypg3ru4tvuxkrjy@jpeg'); background-size: cover; background-position: center;" 106 119 > 107 120 <Login hx-target="#login" error={error} errorClass="text-white" /> 108 121 <div class="absolute bottom-2 right-2 text-white text-sm"> ··· 134 147 if (!profile) return ctx.next(); 135 148 let follow: WithBffMeta<BskyFollow> | undefined; 136 149 if (ctx.currentUser) { 137 - follow = getFollow( 138 - profile.did, 139 - ctx.currentUser.did, 140 - ctx, 141 - ); 150 + follow = getFollow(profile.did, ctx.currentUser.did, ctx); 142 151 } 143 152 ctx.state.meta = [ 144 153 { ··· 182 191 ...getPageMeta(galleryLink(handle, rkey)), 183 192 ...getGalleryMeta(gallery), 184 193 ]; 185 - ctx.state.scripts = ["photo_dialog.js", "masonry.js"]; 194 + ctx.state.scripts = ["photo_dialog.js", "masonry.js", "sortable.js"]; 186 195 return ctx.render( 187 196 <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, 188 197 ); ··· 214 223 createdAt: new Date().toISOString(), 215 224 }, 216 225 ); 217 - return ctx.html( 218 - <FollowButton followeeDid={did} followUri={followUri} />, 219 - ); 226 + return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />); 220 227 }), 221 228 route("/follow/:did/:rkey", ["DELETE"], async (_req, params, ctx) => { 222 229 requireAuth(ctx); ··· 226 233 await ctx.deleteRecord( 227 234 `at://${ctx.currentUser.did}/app.bsky.graph.follow/${rkey}`, 228 235 ); 229 - return ctx.html( 230 - <FollowButton followeeDid={did} followUri={undefined} />, 231 - ); 236 + return ctx.html(<FollowButton followeeDid={did} followUri={undefined} />); 232 237 }), 233 238 route("/dialogs/gallery/new", (_req, _params, ctx) => { 234 239 requireAuth(ctx); ··· 241 246 const gallery = getGallery(handle, rkey, ctx); 242 247 return ctx.html(<GalleryCreateEditDialog gallery={gallery} />); 243 248 }), 249 + route("/dialogs/gallery/:rkey/sort", (_req, params, ctx) => { 250 + requireAuth(ctx); 251 + const handle = ctx.currentUser.handle; 252 + const rkey = params.rkey; 253 + const gallery = getGallery(handle, rkey, ctx); 254 + if (!gallery) return ctx.next(); 255 + return ctx.html(<GallerySortDialog gallery={gallery} />); 256 + }), 244 257 route("/onboard", (_req, _params, ctx) => { 245 258 requireAuth(ctx); 246 259 return ctx.render( ··· 308 321 />, 309 322 ); 310 323 }), 311 - route("/dialogs/image-alt", (req, _params, ctx) => { 312 - const url = new URL(req.url); 313 - const galleryUri = url.searchParams.get("galleryUri"); 314 - const imageCid = url.searchParams.get("imageCid"); 315 - if (!galleryUri || !imageCid) return ctx.next(); 316 - const atUri = new AtUri(galleryUri); 317 - const galleryDid = atUri.hostname; 318 - const galleryRkey = atUri.rkey; 319 - const gallery = getGallery(galleryDid, galleryRkey, ctx); 320 - const photo = gallery?.items?.filter(isPhotoView).find((photo) => { 321 - return photo.cid === imageCid; 322 - }); 323 - if (!photo || !gallery) return ctx.next(); 324 + route("/dialogs/photo/:rkey/alt", (_req, params, ctx) => { 325 + requireAuth(ctx); 326 + const photoRkey = params.rkey; 327 + const photoUri = 328 + `at://${ctx.currentUser.did}/social.grain.photo/${photoRkey}`; 329 + const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>(photoUri); 330 + if (!photo) return ctx.next(); 324 331 return ctx.html( 325 - <PhotoAltDialog galleryUri={gallery.uri} photo={photo} />, 332 + <PhotoAltDialog photo={photoToView(ctx.currentUser.did, photo)} />, 326 333 ); 327 334 }), 328 335 route("/dialogs/photo-select/:galleryRkey", (_req, params, ctx) => { ··· 430 437 key={photo.cid} 431 438 photo={photoToView(photo.did, photo)} 432 439 gallery={gallery} 433 - isCreator={ctx.currentUser.did === gallery.creator.did} 434 - isLoggedIn={!!ctx.currentUser.did} 435 440 /> 436 441 </div> 437 442 <PhotoSelectButton ··· 508 513 }); 509 514 return new Response(null, { status: 200 }); 510 515 }), 516 + route("/actions/photo/:rkey", ["DELETE"], (_req, params, ctx) => { 517 + requireAuth(ctx); 518 + ctx.deleteRecord( 519 + `at://${ctx.currentUser.did}/social.grain.photo/${params.rkey}`, 520 + ); 521 + return new Response(null, { status: 200 }); 522 + }), 511 523 route("/actions/favorite", ["POST"], async (req, _params, ctx) => { 512 524 requireAuth(ctx); 513 525 const url = new URL(req.url); ··· 566 578 567 579 return ctx.redirect(`/profile/${ctx.currentUser.handle}`); 568 580 }), 569 - route("/actions/photo/:rkey", ["DELETE"], (_req, params, ctx) => { 570 - requireAuth(ctx); 571 - ctx.deleteRecord( 572 - `at://${ctx.currentUser.did}/social.grain.photo/${params.rkey}`, 573 - ); 574 - return new Response(null, { status: 200 }); 575 - }), 581 + route( 582 + "/actions/gallery/:rkey/sort", 583 + ["POST"], 584 + async (req, params, ctx) => { 585 + requireAuth(ctx); 586 + const galleryRkey = params.rkey; 587 + const galleryUri = 588 + `at://${ctx.currentUser.did}/social.grain.gallery/${galleryRkey}`; 589 + const { 590 + items, 591 + } = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>( 592 + "social.grain.gallery.item", 593 + { 594 + where: [ 595 + { 596 + field: "gallery", 597 + equals: galleryUri, 598 + }, 599 + ], 600 + }, 601 + ); 602 + const itemsMap = new Map<string, WithBffMeta<GalleryItem>>(); 603 + for (const item of items) { 604 + itemsMap.set(item.item, item); 605 + } 606 + const formData = await req.formData(); 607 + const sortedItems = formData.getAll("item") as string[]; 608 + const updates = []; 609 + let position = 0; 610 + for (const sortedItemUri of sortedItems) { 611 + const item = itemsMap.get(sortedItemUri); 612 + if (!item) continue; 613 + updates.push({ 614 + collection: "social.grain.gallery.item", 615 + rkey: new AtUri(item.uri).rkey, 616 + data: { 617 + gallery: item.gallery, 618 + item: item.item, 619 + createdAt: item.createdAt, 620 + position, 621 + }, 622 + }); 623 + position++; 624 + } 625 + await ctx.updateRecords<WithBffMeta<GalleryItem>>(updates); 626 + return ctx.redirect( 627 + `/profile/${ctx.currentUser.handle}/${galleryRkey}`, 628 + ); 629 + }, 630 + ), 576 631 ...photoUploadRoutes(), 577 632 ...avatarUploadRoutes(), 578 633 ], ··· 657 712 }; 658 713 659 714 function getFollow(followeeDid: string, followerDid: string, ctx: BffContext) { 660 - const { items: [follow] } = ctx.indexService.getRecords< 661 - WithBffMeta<BskyFollow> 662 - >( 715 + const { 716 + items: [follow], 717 + } = ctx.indexService.getRecords<WithBffMeta<BskyFollow>>( 663 718 "app.bsky.graph.follow", 664 719 { 665 720 where: [ ··· 691 746 const { items: galleryItems } = ctx.indexService.getRecords< 692 747 WithBffMeta<GalleryItem> 693 748 >("social.grain.gallery.item", { 694 - orderBy: { field: "createdAt", direction: "asc" }, 749 + orderBy: { field: "position", direction: "asc" }, 695 750 where: [{ field: "gallery", in: galleryUris }], 696 751 }); 697 752 ··· 1045 1100 : null} 1046 1101 <script src="https://unpkg.com/htmx.org@1.9.10" /> 1047 1102 <script src="https://unpkg.com/hyperscript.org@0.9.14" /> 1103 + <script src="https://unpkg.com/sortablejs@1.15.6" /> 1048 1104 <style dangerouslySetInnerHTML={{ __html: CSS }} /> 1049 - <link rel="stylesheet" href={`/static/styles.css?${cssContentHash}`} /> 1105 + <link 1106 + rel="stylesheet" 1107 + href={`/static/styles.css?${staticFilesHash.get("styles.css")}`} 1108 + /> 1050 1109 <link rel="preconnect" href="https://fonts.googleapis.com" /> 1051 1110 <link 1052 1111 rel="preconnect" ··· 1062 1121 href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 1063 1122 preload 1064 1123 /> 1065 - {scripts?.map((file) => <script key={file} src={`/static/${file}`} />)} 1124 + {scripts?.map((file) => ( 1125 + <script 1126 + key={file} 1127 + src={`/static/${file}?${staticFilesHash.get(file)}`} 1128 + /> 1129 + ))} 1066 1130 </head> 1067 1131 <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 1068 - <Layout id="layout" class="dark:border-zinc-800"> 1132 + <Layout id="layout" class="border-zinc-200 dark:border-zinc-800"> 1069 1133 <Layout.Nav 1070 1134 heading={ 1071 1135 <h1 class="font-['Jersey_20'] text-4xl text-zinc-900 dark:text-white"> ··· 1074 1138 </h1> 1075 1139 } 1076 1140 profile={profile} 1077 - class="dark:border-zinc-800" 1141 + class="border-zinc-200 dark:border-zinc-800" 1078 1142 /> 1079 1143 <Layout.Content>{props.children}</Layout.Content> 1080 1144 </Layout> ··· 1137 1201 ); 1138 1202 } 1139 1203 1204 + function ActorInfo({ profile }: Readonly<{ profile: Un$Typed<ProfileView> }>) { 1205 + return ( 1206 + <div class="flex items-center gap-2 min-w-0 flex-1"> 1207 + <img 1208 + src={profile.avatar} 1209 + alt={profile.handle} 1210 + class="rounded-full object-cover size-7 shrink-0" 1211 + /> 1212 + <a 1213 + href={profileLink(profile.handle)} 1214 + class="hover:underline text-zinc-600 dark:text-zinc-500 truncate max-w-[300px] sm:max-w-[400px]" 1215 + > 1216 + <span class="text-zinc-950 dark:text-zinc-50 font-semibold text-"> 1217 + {profile.displayName || profile.handle} 1218 + </span>{" "} 1219 + <span class="truncate">@{profile.handle}</span> 1220 + </a> 1221 + </div> 1222 + ); 1223 + } 1224 + 1140 1225 function Timeline({ items }: Readonly<{ items: TimelineItem[] }>) { 1141 1226 return ( 1142 1227 <div class="px-4 mb-4"> ··· 1152 1237 1153 1238 function TimelineItem({ item }: Readonly<{ item: TimelineItem }>) { 1154 1239 return ( 1155 - <li class="space-y-2"> 1156 - <div class="bg-zinc-100 dark:bg-zinc-900 w-fit p-2"> 1157 - <a 1158 - href={profileLink(item.actor.handle)} 1159 - class="font-semibold hover:underline" 1160 - > 1161 - @{item.actor.handle} 1162 - </a>{" "} 1163 - {item.itemType === "favorite" ? "favorited" : "created"}{" "} 1164 - <a 1165 - href={galleryLink( 1166 - item.gallery.creator.handle, 1167 - new AtUri(item.gallery.uri).rkey, 1168 - )} 1169 - class="font-semibold hover:underline" 1170 - > 1171 - {(item.gallery.record as Gallery).title} 1172 - </a> 1173 - <span class="ml-1"> 1174 - {formatDistanceStrict(item.createdAt, new Date(), { 1175 - addSuffix: true, 1176 - })} 1177 - </span> 1178 - </div> 1179 - <a 1180 - href={galleryLink( 1181 - item.gallery.creator.handle, 1182 - new AtUri(item.gallery.uri).rkey, 1183 - )} 1184 - class="w-fit flex" 1185 - > 1240 + <li> 1241 + <div class="w-fit flex flex-col gap-4 pb-4 border-b border-zinc-200 dark:border-zinc-800"> 1242 + <div class="flex items-center justify-between gap-2 w-full"> 1243 + <ActorInfo profile={item.actor} /> 1244 + <span class="shrink-0"> 1245 + {formatRelativeTime(new Date(item.createdAt))} 1246 + </span> 1247 + </div> 1186 1248 {item.gallery.items?.filter(isPhotoView).length 1187 1249 ? ( 1188 - <div class="flex w-full max-w-md mx-auto aspect-[3/2] overflow-hidden gap-2"> 1250 + <a 1251 + href={galleryLink( 1252 + item.gallery.creator.handle, 1253 + new AtUri(item.gallery.uri).rkey, 1254 + )} 1255 + class="flex w-full max-w-md mx-auto aspect-[3/2] overflow-hidden gap-2" 1256 + > 1189 1257 <div class="w-2/3 h-full"> 1190 1258 <img 1191 1259 src={item.gallery.items?.filter(isPhotoView)[0].thumb} ··· 1223 1291 )} 1224 1292 </div> 1225 1293 </div> 1226 - </div> 1294 + </a> 1227 1295 ) 1228 1296 : null} 1229 - </a> 1297 + <p> 1298 + {item.itemType === "favorite" ? "Favorited" : "Created"}{" "} 1299 + <a 1300 + href={galleryLink( 1301 + item.gallery.creator.handle, 1302 + new AtUri(item.gallery.uri).rkey, 1303 + )} 1304 + class="font-semibold hover:underline" 1305 + > 1306 + {(item.gallery.record as Gallery).title} 1307 + </a> 1308 + </p> 1309 + </div> 1230 1310 </li> 1231 1311 ); 1232 1312 } ··· 1252 1332 : { 1253 1333 children: ( 1254 1334 <> 1255 - <i class="fa-solid fa-plus mr-2" />Follow 1335 + <i class="fa-solid fa-plus mr-2" /> 1336 + Follow 1256 1337 </> 1257 1338 ), 1258 1339 "hx-post": `/follow/${followeeDid}`, ··· 1262 1343 hx-swap="outerHTML" 1263 1344 /> 1264 1345 ); 1346 + } 1347 + 1348 + function formatRelativeTime(date: Date) { 1349 + const now = new Date(); 1350 + const weeks = differenceInWeeks(now, date); 1351 + if (weeks > 0) return `${weeks}w`; 1352 + 1353 + const days = differenceInDays(now, date); 1354 + if (days > 0) return `${days}d`; 1355 + 1356 + const hours = differenceInHours(now, date); 1357 + if (hours > 0) return `${hours}h`; 1358 + 1359 + const minutes = differenceInMinutes(now, date); 1360 + return `${Math.max(1, minutes)}m`; 1265 1361 } 1266 1362 1267 1363 function ProfilePage({ ··· 1280 1376 galleries?: GalleryView[]; 1281 1377 }>) { 1282 1378 const isCreator = loggedInUserDid === profile.did; 1379 + const displayName = profile.displayName || profile.handle; 1283 1380 return ( 1284 1381 <div class="px-4 mb-4" id="profile-page"> 1285 1382 <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> 1286 - <div class="flex flex-col"> 1383 + <div class="flex flex-col mb-4"> 1287 1384 <AvatarButton profile={profile} /> 1288 - <p class="text-2xl font-bold">{profile.displayName}</p> 1385 + <p class="text-2xl font-bold">{displayName}</p> 1289 1386 <p class="text-zinc-600 dark:text-zinc-500">@{profile.handle}</p> 1290 - <p class="my-2">{profile.description}</p> 1387 + {profile.description 1388 + ? <p class="mt-2">{profile.description}</p> 1389 + : null} 1291 1390 </div> 1292 1391 {!isCreator && loggedInUserDid 1293 1392 ? ( ··· 1377 1476 : null} 1378 1477 {selectedTab === "galleries" 1379 1478 ? ( 1380 - <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"> 1479 + <div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4"> 1381 1480 {galleries?.length 1382 1481 ? ( 1383 1482 galleries.map((gallery) => ( ··· 1391 1490 {gallery.items?.length 1392 1491 ? ( 1393 1492 <img 1394 - src={gallery.items?.filter(isPhotoView)?.[0]?.thumb} 1493 + src={gallery.items?.filter(isPhotoView)?.[0] 1494 + ?.fullsize} 1395 1495 alt={gallery.items?.filter(isPhotoView)?.[0]?.alt} 1396 1496 class="w-full h-full object-cover" 1397 1497 /> ··· 1414 1514 ); 1415 1515 } 1416 1516 1417 - function UploadPage( 1418 - { handle, photos, returnTo }: Readonly< 1419 - { handle: string; photos: PhotoView[]; returnTo?: string } 1420 - >, 1421 - ) { 1517 + function UploadPage({ 1518 + handle, 1519 + photos, 1520 + returnTo, 1521 + }: Readonly<{ handle: string; photos: PhotoView[]; returnTo?: string }>) { 1422 1522 return ( 1423 1523 <div class="flex flex-col px-4 pt-4 mb-4 space-y-4"> 1424 1524 <div class="flex"> 1425 1525 <div class="flex-1"> 1426 1526 {returnTo 1427 1527 ? ( 1428 - <a 1429 - href={returnTo} 1430 - class="hover:underline" 1431 - > 1528 + <a href={returnTo} class="hover:underline"> 1432 1529 <i class="fa-solid fa-arrow-left mr-2" /> 1433 1530 Back to gallery 1434 1531 </a> ··· 1440 1537 </a> 1441 1538 )} 1442 1539 </div> 1443 - <div>10/100 photos</div> 1444 1540 </div> 1445 - <Button variant="primary" class="mb-4" asChild> 1446 - <label class="w-fit"> 1541 + <Button variant="primary" class="mb-4 w-full sm:w-fit" asChild> 1542 + <label> 1447 1543 <i class="fa fa-plus"></i> Add photos 1448 1544 <input 1449 1545 class="hidden" ··· 1488 1584 }>) { 1489 1585 return ( 1490 1586 <Dialog> 1491 - <Dialog.Content class="dark:bg-zinc-950"> 1587 + <Dialog.Content class="dark:bg-zinc-950 relative"> 1588 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 1492 1589 <Dialog.Title>Edit my profile</Dialog.Title> 1493 1590 <div> 1494 1591 <AvatarForm src={profile.avatar} alt={profile.handle} /> ··· 1510 1607 name="displayName" 1511 1608 class="dark:bg-zinc-800 dark:text-white" 1512 1609 value={profile.displayName} 1610 + autoFocus 1513 1611 /> 1514 1612 </div> 1515 1613 <div class="mb-4 relative"> ··· 1591 1689 }>) { 1592 1690 const isCreator = currentUserDid === gallery.creator.did; 1593 1691 const isLoggedIn = !!currentUserDid; 1692 + const description = (gallery.record as Gallery).description; 1594 1693 return ( 1595 1694 <div class="px-4"> 1596 - <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between my-4"> 1597 - <div class="flex flex-col space-y-1 mb-4"> 1695 + <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-4 mb-2"> 1696 + <div class="flex flex-col space-y-2 mb-4"> 1598 1697 <h1 class="font-bold text-2xl"> 1599 1698 {(gallery.record as Gallery).title} 1600 1699 </h1> 1601 - <div> 1602 - Gallery by{" "} 1603 - <a 1604 - href={profileLink(gallery.creator.handle)} 1605 - class="hover:underline" 1606 - > 1607 - <span class="font-semibold">{gallery.creator.displayName}</span> 1608 - {" "} 1609 - <span class="text-zinc-600 dark:text-zinc-500"> 1610 - @{gallery.creator.handle} 1611 - </span> 1612 - </a> 1613 - </div> 1614 - <p>{(gallery.record as Gallery).description}</p> 1700 + <ActorInfo profile={gallery.creator} /> 1701 + {description ? <p>{description}</p> : null} 1615 1702 </div> 1616 1703 {isLoggedIn && isCreator 1617 1704 ? ( 1618 1705 <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1619 1706 <Button 1707 + variant="primary" 1708 + class="self-start w-full sm:w-fit" 1709 + hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`} 1710 + hx-target="#layout" 1711 + hx-swap="afterbegin" 1712 + > 1713 + Edit 1714 + </Button> 1715 + <Button 1620 1716 hx-get={`/dialogs/photo-select/${new AtUri(gallery.uri).rkey}`} 1621 1717 hx-target="#layout" 1622 1718 hx-swap="afterbegin" ··· 1628 1724 <Button 1629 1725 variant="primary" 1630 1726 class="self-start w-full sm:w-fit" 1631 - hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}`} 1727 + hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/sort`} 1632 1728 hx-target="#layout" 1633 1729 hx-swap="afterbegin" 1634 1730 > 1635 - Edit 1731 + Sort order 1636 1732 </Button> 1733 + <ShareGalleryButton gallery={gallery} /> 1637 1734 </div> 1638 1735 ) 1639 1736 : null} 1640 1737 {!isCreator 1641 1738 ? ( 1642 - <FavoriteButton 1643 - currentUserDid={currentUserDid} 1644 - favs={favs} 1645 - galleryUri={gallery.uri} 1646 - /> 1739 + <div class="flex self-start gap-2 w-full sm:w-fit flex-col sm:flex-row"> 1740 + <ShareGalleryButton gallery={gallery} /> 1741 + <FavoriteButton 1742 + currentUserDid={currentUserDid} 1743 + favs={favs} 1744 + galleryUri={gallery.uri} 1745 + /> 1746 + </div> 1647 1747 ) 1648 1748 : null} 1649 1749 </div> 1750 + <div class="flex justify-end mb-2"> 1751 + <Button 1752 + id="justified-button" 1753 + variant="primary" 1754 + class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 1755 + _="on click call toggleLayout('justified') 1756 + set @data-selected to 'true' 1757 + set #masonry-button's @data-selected to 'false'" 1758 + > 1759 + <svg 1760 + width="24" 1761 + height="24" 1762 + viewBox="0 0 24 24" 1763 + xmlns="http://www.w3.org/2000/svg" 1764 + > 1765 + <rect x="2" y="2" width="8" height="6" fill="currentColor" rx="1" /> 1766 + <rect 1767 + x="12" 1768 + y="2" 1769 + width="10" 1770 + height="6" 1771 + fill="currentColor" 1772 + rx="1" 1773 + /> 1774 + <rect 1775 + x="2" 1776 + y="10" 1777 + width="6" 1778 + height="6" 1779 + fill="currentColor" 1780 + rx="1" 1781 + /> 1782 + <rect 1783 + x="10" 1784 + y="10" 1785 + width="12" 1786 + height="6" 1787 + fill="currentColor" 1788 + rx="1" 1789 + /> 1790 + <rect 1791 + x="2" 1792 + y="18" 1793 + width="20" 1794 + height="4" 1795 + fill="currentColor" 1796 + rx="1" 1797 + /> 1798 + </svg> 1799 + </Button> 1800 + <Button 1801 + id="masonry-button" 1802 + variant="primary" 1803 + data-selected="false" 1804 + class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 1805 + _="on click call toggleLayout('masonry') 1806 + set @data-selected to 'true' 1807 + set #justified-button's @data-selected to 'false'" 1808 + > 1809 + <svg 1810 + width="24" 1811 + height="24" 1812 + viewBox="0 0 24 24" 1813 + xmlns="http://www.w3.org/2000/svg" 1814 + > 1815 + <rect x="2" y="2" width="8" height="8" fill="currentColor" rx="1" /> 1816 + <rect 1817 + x="12" 1818 + y="2" 1819 + width="8" 1820 + height="4" 1821 + fill="currentColor" 1822 + rx="1" 1823 + /> 1824 + <rect 1825 + x="12" 1826 + y="8" 1827 + width="8" 1828 + height="6" 1829 + fill="currentColor" 1830 + rx="1" 1831 + /> 1832 + <rect 1833 + x="2" 1834 + y="12" 1835 + width="8" 1836 + height="8" 1837 + fill="currentColor" 1838 + rx="1" 1839 + /> 1840 + <rect 1841 + x="12" 1842 + y="16" 1843 + width="8" 1844 + height="4" 1845 + fill="currentColor" 1846 + rx="1" 1847 + /> 1848 + </svg> 1849 + </Button> 1850 + </div> 1650 1851 <div 1651 1852 id="masonry-container" 1652 1853 class="h-0 overflow-hidden relative mx-auto w-full" 1653 - _="on load or htmx:afterSettle call computeMasonry()" 1854 + _="on load or htmx:afterSettle call computeLayout()" 1654 1855 > 1655 1856 {gallery.items?.filter(isPhotoView)?.length 1656 1857 ? gallery?.items ··· 1660 1861 key={photo.cid} 1661 1862 photo={photo} 1662 1863 gallery={gallery} 1663 - isCreator={isCreator} 1664 - isLoggedIn={isLoggedIn} 1665 1864 /> 1666 1865 )) 1667 1866 : null} ··· 1673 1872 function PhotoButton({ 1674 1873 photo, 1675 1874 gallery, 1676 - isCreator, 1677 - isLoggedIn, 1678 1875 }: Readonly<{ 1679 1876 photo: PhotoView; 1680 1877 gallery: GalleryView; 1681 - isCreator: boolean; 1682 - isLoggedIn: boolean; 1683 1878 }>) { 1684 1879 return ( 1685 1880 <button ··· 1693 1888 data-width={photo.aspectRatio?.width} 1694 1889 data-height={photo.aspectRatio?.height} 1695 1890 > 1696 - {isLoggedIn && isCreator 1697 - ? <AltTextButton galleryUri={gallery.uri} cid={photo.cid} /> 1698 - : null} 1699 1891 <img 1700 1892 src={photo.fullsize} 1701 1893 alt={photo.alt} 1702 1894 class="w-full h-full object-cover" 1703 1895 /> 1704 - {!isCreator && photo.alt 1896 + {photo.alt 1705 1897 ? ( 1706 1898 <div class="absolute bg-zinc-950 dark:bg-zinc-900 bottom-1 right-1 sm:bottom-1 sm:right-1 text-xs text-white font-semibold py-[1px] px-[3px]"> 1707 1899 ALT ··· 1712 1904 ); 1713 1905 } 1714 1906 1907 + function GallerySortDialog({ gallery }: Readonly<{ gallery: GalleryView }>) { 1908 + return ( 1909 + <Dialog> 1910 + <Dialog.Content class="dark:bg-zinc-950 relative"> 1911 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 1912 + <Dialog.Title>Sort gallery</Dialog.Title> 1913 + <p class="my-2 text-center">Drag photos to rearrange</p> 1914 + <form 1915 + hx-post={`/actions/gallery/${new AtUri(gallery.uri).rkey}/sort`} 1916 + hx-trigger="submit" 1917 + hx-swap="none" 1918 + > 1919 + <div class="sortable grid grid-cols-3 sm:grid-cols-5 gap-2 mt-2"> 1920 + {gallery?.items?.filter(isPhotoView).map((item) => ( 1921 + <div 1922 + key={item.cid} 1923 + class="relative aspect-square cursor-grab" 1924 + > 1925 + <input type="hidden" name="item" value={item.uri} /> 1926 + <img 1927 + src={item.fullsize} 1928 + alt={item.alt} 1929 + class="w-full h-full absolute object-cover" 1930 + /> 1931 + </div> 1932 + ))} 1933 + </div> 1934 + <div class="flex flex-col gap-2 mt-2"> 1935 + <Button 1936 + variant="primary" 1937 + type="submit" 1938 + class="w-full" 1939 + > 1940 + Save 1941 + </Button> 1942 + <Button 1943 + variant="secondary" 1944 + type="button" 1945 + class="w-full" 1946 + _={Dialog._closeOnClick} 1947 + > 1948 + Cancel 1949 + </Button> 1950 + </div> 1951 + </form> 1952 + </Dialog.Content> 1953 + </Dialog> 1954 + ); 1955 + } 1956 + 1957 + function ShareGalleryButton({ gallery }: Readonly<{ gallery: GalleryView }>) { 1958 + return ( 1959 + <> 1960 + <input 1961 + type="hidden" 1962 + id="copy-text" 1963 + value={publicGalleryLink(gallery.creator.handle, gallery.uri)} 1964 + /> 1965 + <Button 1966 + variant="primary" 1967 + _={`on click 1968 + set copyText to #copy-text.value 1969 + writeText(copyText) on navigator.clipboard 1970 + alert('Copied to clipboard')`} 1971 + > 1972 + <i class="fa-solid fa-share-nodes mr-2" /> 1973 + Share 1974 + </Button> 1975 + </> 1976 + ); 1977 + } 1978 + 1715 1979 function FavoriteButton({ 1716 1980 currentUserDid, 1717 1981 favs = [], ··· 1744 2008 }: Readonly<{ gallery?: GalleryView | null }>) { 1745 2009 return ( 1746 2010 <Dialog id="gallery-dialog" class="z-30"> 1747 - <Dialog.Content class="dark:bg-zinc-950"> 2011 + <Dialog.Content class="dark:bg-zinc-950 relative"> 2012 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 1748 2013 <Dialog.Title> 1749 2014 {gallery ? "Edit gallery" : "Create a new gallery"} 1750 2015 </Dialog.Title> ··· 1841 2106 }>) { 1842 2107 return ( 1843 2108 <div class="relative aspect-square bg-zinc-200 dark:bg-zinc-900"> 2109 + {uri ? <AltTextButton photoUri={uri} /> : null} 1844 2110 {uri 1845 2111 ? ( 1846 2112 <button ··· 1863 2129 ); 1864 2130 } 1865 2131 1866 - function AltTextButton({ 1867 - galleryUri, 1868 - cid, 1869 - }: Readonly<{ galleryUri: string; cid: string }>) { 2132 + function AltTextButton({ photoUri }: Readonly<{ photoUri: string }>) { 1870 2133 return ( 1871 2134 <div 1872 - class="bg-zinc-950 dark:bg-zinc-900 py-[1px] px-[3px] absolute top-1 left-1 sm:top-1 sm:left-1 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" 1873 - hx-get={`/dialogs/image-alt?galleryUri=${galleryUri}&imageCid=${cid}`} 2135 + 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" 2136 + hx-get={`/dialogs/photo/${new AtUri(photoUri).rkey}/alt`} 1874 2137 hx-trigger="click" 1875 2138 hx-target="#layout" 1876 2139 hx-swap="afterbegin" ··· 1894 2157 }>) { 1895 2158 return ( 1896 2159 <Dialog id="photo-dialog" class="bg-zinc-950 z-30"> 2160 + <Dialog.X /> 1897 2161 {nextImage 1898 2162 ? ( 1899 2163 <div ··· 1939 2203 1940 2204 function PhotoAltDialog({ 1941 2205 photo, 1942 - galleryUri, 1943 2206 }: Readonly<{ 1944 2207 photo: PhotoView; 1945 - galleryUri: string; 1946 2208 }>) { 1947 2209 return ( 1948 2210 <Dialog id="photo-alt-dialog" class="z-30"> 1949 - <Dialog.Content class="dark:bg-zinc-950"> 2211 + <Dialog.Content class="dark:bg-zinc-950 relative"> 2212 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 1950 2213 <Dialog.Title>Add alt text</Dialog.Title> 1951 2214 <div class="aspect-square relative"> 1952 2215 <img ··· 1959 2222 hx-put={`/actions/photo/${new AtUri(photo.uri).rkey}`} 1960 2223 _="on htmx:afterOnLoad trigger closeDialog" 1961 2224 > 1962 - <input type="hidden" name="galleryUri" value={galleryUri} /> 1963 - <input type="hidden" name="cid" value={photo.cid} /> 1964 2225 <div class="my-2"> 1965 2226 <label htmlFor="alt">Descriptive alt text</label> 1966 2227 <Textarea ··· 1996 2257 }>) { 1997 2258 return ( 1998 2259 <Dialog id="photo-select-dialog" class="z-30"> 1999 - <Dialog.Content class="w-full max-w-5xl dark:bg-zinc-950 sm:min-h-screen flex flex-col"> 2260 + <Dialog.Content class="w-full max-w-5xl dark:bg-zinc-950 sm:min-h-screen flex flex-col relative"> 2261 + <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 2000 2262 <Dialog.Title>Add photos</Dialog.Title> 2001 2263 {photos.length 2002 2264 ? ( ··· 2008 2270 : null} 2009 2271 {photos.length 2010 2272 ? ( 2011 - <div class="grid grid-cols-2 sm:grid-cols-3 gap-4 my-4 flex-1"> 2273 + <div class="grid grid-cols-3 sm:grid-cols-5 gap-4 my-4 flex-1"> 2012 2274 {photos.map((photo) => ( 2013 2275 <PhotoSelectButton 2014 2276 key={photo.cid} ··· 2069 2331 set @data-added to 'true' 2070 2332 end`} 2071 2333 > 2072 - <div class="hidden group-data-[added=true]:block absolute top-2 right-2"> 2334 + <div class="hidden group-data-[added=true]:block absolute top-2 right-2 z-30"> 2073 2335 <i class="fa-check fa-solid text-sky-500 z-10" /> 2074 2336 </div> 2075 2337 <img ··· 2130 2392 uri: photo.uri, 2131 2393 cid: photo.photo.ref.toString(), 2132 2394 thumb: 2133 - `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${photo.photo.ref.toString()}@webp`, 2395 + `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${photo.photo.ref.toString()}@jpeg`, 2134 2396 fullsize: 2135 - `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${photo.photo.ref.toString()}@webp`, 2397 + `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${photo.photo.ref.toString()}@jpeg`, 2136 2398 alt: photo.alt, 2137 2399 aspectRatio: photo.aspectRatio, 2138 2400 }; ··· 2394 2656 ), 2395 2657 ]; 2396 2658 } 2659 + 2660 + function publicGalleryLink(handle: string, galleryUri: string): string { 2661 + return `${PUBLIC_URL}/profile/${handle}/${new AtUri(galleryUri).rkey}`; 2662 + }
+85 -3
static/masonry.js
··· 1 1 // deno-lint-ignore-file 2 2 3 3 let masonryObserverInitialized = false; 4 + let layoutMode = "justified"; 5 + 6 + function computeLayout() { 7 + if (layoutMode === "masonry") { 8 + computeMasonry(); 9 + } else { 10 + computeJustified(); 11 + } 12 + } 13 + 14 + function toggleLayout(layout = "justified") { 15 + layoutMode = layout; 16 + computeLayout(); 17 + } 4 18 5 19 function computeMasonry() { 6 20 const container = document.getElementById("masonry-container"); 7 21 if (!container) return; 8 22 9 - const spacing = 12; 23 + const spacing = 8; 10 24 const containerWidth = container.offsetWidth; 11 25 12 26 if (containerWidth === 0) { ··· 52 66 container.style.height = `${Math.max(...columnHeights)}px`; 53 67 } 54 68 69 + function computeJustified() { 70 + const container = document.getElementById("masonry-container"); 71 + if (!container) return; 72 + 73 + const spacing = 8; 74 + const containerWidth = container.offsetWidth; 75 + 76 + if (containerWidth === 0) { 77 + requestAnimationFrame(computeJustified); 78 + return; 79 + } 80 + 81 + const tiles = Array.from(container.querySelectorAll(".masonry-tile")); 82 + let currentRow = []; 83 + let rowAspectRatioSum = 0; 84 + let yOffset = 0; 85 + 86 + // Clear all styles before layout 87 + tiles.forEach((tile) => { 88 + Object.assign(tile.style, { 89 + position: "absolute", 90 + left: "0px", 91 + top: "0px", 92 + width: "auto", 93 + height: "auto", 94 + }); 95 + }); 96 + 97 + for (let i = 0; i < tiles.length; i++) { 98 + const tile = tiles[i]; 99 + const imgW = parseFloat(tile.dataset.width); 100 + const imgH = parseFloat(tile.dataset.height); 101 + if (!imgW || !imgH) continue; 102 + 103 + const aspectRatio = imgW / imgH; 104 + currentRow.push({ tile, aspectRatio, imgW, imgH }); 105 + rowAspectRatioSum += aspectRatio; 106 + 107 + // Estimate if row is "full" enough 108 + const estimatedRowHeight = 109 + (containerWidth - (currentRow.length - 1) * spacing) / rowAspectRatioSum; 110 + 111 + // If height is reasonable or we're at the end, render the row 112 + if (estimatedRowHeight < 300 || i === tiles.length - 1) { 113 + let xOffset = 0; 114 + 115 + for (const item of currentRow) { 116 + const width = estimatedRowHeight * item.aspectRatio; 117 + Object.assign(item.tile.style, { 118 + position: "absolute", 119 + top: `${yOffset}px`, 120 + left: `${xOffset}px`, 121 + width: `${width}px`, 122 + height: `${estimatedRowHeight}px`, 123 + }); 124 + xOffset += width + spacing; 125 + } 126 + 127 + yOffset += estimatedRowHeight + spacing; 128 + currentRow = []; 129 + rowAspectRatioSum = 0; 130 + } 131 + } 132 + 133 + container.style.position = "relative"; 134 + container.style.height = `${yOffset}px`; 135 + } 136 + 55 137 function observeMasonry() { 56 138 if (masonryObserverInitialized) return; 57 139 masonryObserverInitialized = true; ··· 61 143 62 144 // Observe parent resize 63 145 if (typeof ResizeObserver !== "undefined") { 64 - const resizeObserver = new ResizeObserver(() => computeMasonry()); 146 + const resizeObserver = new ResizeObserver(() => computeLayout()); 65 147 if (container.parentElement) { 66 148 resizeObserver.observe(container.parentElement); 67 149 } ··· 69 151 70 152 // Observe inner content changes (tiles being added/removed) 71 153 const mutationObserver = new MutationObserver(() => { 72 - computeMasonry(); 154 + computeLayout(); 73 155 }); 74 156 75 157 mutationObserver.observe(container, {
+8
static/sortable.js
··· 1 + htmx.onLoad(function (content) { 2 + const sortables = content.querySelectorAll(".sortable"); 3 + for (const sortable of sortables) { 4 + new Sortable(sortable, { 5 + animation: 150, 6 + }); 7 + } 8 + });
+149 -35
static/styles.css
··· 8 8 --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 9 9 "Courier New", monospace; 10 10 --color-sky-500: oklch(68.5% 0.169 237.323); 11 + --color-zinc-50: oklch(98.5% 0 0); 11 12 --color-zinc-100: oklch(96.7% 0.001 286.375); 12 13 --color-zinc-200: oklch(92% 0.004 286.32); 13 14 --color-zinc-500: oklch(55.2% 0.016 285.938); ··· 206 207 .inset-0 { 207 208 inset: calc(var(--spacing) * 0); 208 209 } 209 - .top-1 { 210 - top: calc(var(--spacing) * 1); 211 - } 212 210 .top-2 { 213 211 top: calc(var(--spacing) * 2); 214 212 } ··· 236 234 .left-0 { 237 235 left: calc(var(--spacing) * 0); 238 236 } 239 - .left-1 { 240 - left: calc(var(--spacing) * 1); 237 + .left-2 { 238 + left: calc(var(--spacing) * 2); 241 239 } 242 240 .z-10 { 243 241 z-index: 10; ··· 281 279 .mt-2 { 282 280 margin-top: calc(var(--spacing) * 2); 283 281 } 282 + .mt-4 { 283 + margin-top: calc(var(--spacing) * 4); 284 + } 284 285 .mr-1 { 285 286 margin-right: calc(var(--spacing) * 1); 286 287 } ··· 293 294 .mb-4 { 294 295 margin-bottom: calc(var(--spacing) * 4); 295 296 } 296 - .ml-1 { 297 - margin-left: calc(var(--spacing) * 1); 298 - } 299 297 .flex { 300 298 display: flex; 301 299 } ··· 314 312 .size-4 { 315 313 width: calc(var(--spacing) * 4); 316 314 height: calc(var(--spacing) * 4); 315 + } 316 + .size-7 { 317 + width: calc(var(--spacing) * 7); 318 + height: calc(var(--spacing) * 7); 317 319 } 318 320 .size-16 { 319 321 width: calc(var(--spacing) * 16); ··· 367 369 .max-w-5xl { 368 370 max-width: var(--container-5xl); 369 371 } 372 + .max-w-\[300px\] { 373 + max-width: 300px; 374 + } 370 375 .max-w-md { 371 376 max-width: var(--container-md); 372 377 } 373 378 .max-w-xl { 374 379 max-width: var(--container-xl); 375 380 } 381 + .min-w-0 { 382 + min-width: calc(var(--spacing) * 0); 383 + } 376 384 .flex-1 { 377 385 flex: 1; 378 386 } 387 + .shrink-0 { 388 + flex-shrink: 0; 389 + } 390 + .cursor-grab { 391 + cursor: grab; 392 + } 379 393 .cursor-pointer { 380 394 cursor: pointer; 381 395 } ··· 388 402 .grid-cols-2 { 389 403 grid-template-columns: repeat(2, minmax(0, 1fr)); 390 404 } 405 + .grid-cols-3 { 406 + grid-template-columns: repeat(3, minmax(0, 1fr)); 407 + } 391 408 .flex-col { 392 409 flex-direction: column; 393 410 } 394 411 .items-center { 395 412 align-items: center; 396 413 } 414 + .justify-between { 415 + justify-content: space-between; 416 + } 397 417 .justify-center { 398 418 justify-content: center; 419 + } 420 + .justify-end { 421 + justify-content: flex-end; 399 422 } 400 423 .gap-2 { 401 424 gap: calc(var(--spacing) * 2); 402 425 } 403 426 .gap-4 { 404 427 gap: calc(var(--spacing) * 4); 405 - } 406 - .space-y-1 { 407 - :where(& > :not(:last-child)) { 408 - --tw-space-y-reverse: 0; 409 - margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); 410 - margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); 411 - } 412 428 } 413 429 .space-y-2 { 414 430 :where(& > :not(:last-child)) { ··· 434 450 .self-start { 435 451 align-self: flex-start; 436 452 } 453 + .truncate { 454 + overflow: hidden; 455 + text-overflow: ellipsis; 456 + white-space: nowrap; 457 + } 437 458 .overflow-hidden { 438 459 overflow: hidden; 439 460 } ··· 443 464 .border { 444 465 border-style: var(--tw-border-style); 445 466 border-width: 1px; 467 + } 468 + .border-b { 469 + border-bottom-style: var(--tw-border-style); 470 + border-bottom-width: 1px; 471 + } 472 + .border-zinc-100 { 473 + border-color: var(--color-zinc-100); 446 474 } 447 475 .border-zinc-200 { 448 476 border-color: var(--color-zinc-200); ··· 459 487 background-color: color-mix(in oklab, var(--color-black) 80%, transparent); 460 488 } 461 489 } 490 + .bg-sky-500 { 491 + background-color: var(--color-sky-500); 492 + } 462 493 .bg-zinc-100 { 463 494 background-color: var(--color-zinc-100); 464 495 } ··· 470 501 } 471 502 .bg-zinc-950 { 472 503 background-color: var(--color-zinc-950); 504 + } 505 + .fill-zinc-950 { 506 + fill: var(--color-zinc-950); 473 507 } 474 508 .object-contain { 475 509 object-fit: contain; ··· 500 534 } 501 535 .pt-4 { 502 536 padding-top: calc(var(--spacing) * 4); 537 + } 538 + .pb-4 { 539 + padding-bottom: calc(var(--spacing) * 4); 503 540 } 504 541 .text-center { 505 542 text-align: center; ··· 556 593 .text-zinc-900 { 557 594 color: var(--color-zinc-900); 558 595 } 596 + .text-zinc-950 { 597 + color: var(--color-zinc-950); 598 + } 559 599 .lowercase { 560 600 text-transform: lowercase; 561 601 } 562 602 .ring-sky-500 { 563 603 --tw-ring-color: var(--color-sky-500); 604 + } 605 + .filter { 606 + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); 564 607 } 565 608 .group-data-\[added\=true\]\:block { 566 609 &:is(:where(.group)[data-added="true"] *) { ··· 585 628 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 586 629 } 587 630 } 631 + .data-\[selected\=false\]\:border-transparent { 632 + &[data-selected="false"] { 633 + border-color: transparent; 634 + } 635 + } 636 + .data-\[selected\=false\]\:bg-transparent { 637 + &[data-selected="false"] { 638 + background-color: transparent; 639 + } 640 + } 588 641 .data-\[state\=pending\]\:opacity-50 { 589 642 &[data-state="pending"] { 590 643 opacity: 50%; 591 - } 592 - } 593 - .sm\:top-1 { 594 - @media (width >= 40rem) { 595 - top: calc(var(--spacing) * 1); 596 644 } 597 645 } 598 646 .sm\:right-1 { ··· 605 653 bottom: calc(var(--spacing) * 1); 606 654 } 607 655 } 608 - .sm\:left-1 { 609 - @media (width >= 40rem) { 610 - left: calc(var(--spacing) * 1); 611 - } 612 - } 613 656 .sm\:h-screen { 614 657 @media (width >= 40rem) { 615 658 height: 100vh; ··· 623 666 .sm\:w-fit { 624 667 @media (width >= 40rem) { 625 668 width: fit-content; 669 + } 670 + } 671 + .sm\:max-w-\[400px\] { 672 + @media (width >= 40rem) { 673 + max-width: 400px; 626 674 } 627 675 } 628 676 .sm\:grid-cols-3 { ··· 675 723 background-color: var(--color-zinc-950); 676 724 } 677 725 } 726 + .dark\:fill-zinc-50 { 727 + @media (prefers-color-scheme: dark) { 728 + fill: var(--color-zinc-50); 729 + } 730 + } 678 731 .dark\:text-white { 679 732 @media (prefers-color-scheme: dark) { 680 733 color: var(--color-white); 681 734 } 682 735 } 736 + .dark\:text-zinc-50 { 737 + @media (prefers-color-scheme: dark) { 738 + color: var(--color-zinc-50); 739 + } 740 + } 683 741 .dark\:text-zinc-500 { 684 742 @media (prefers-color-scheme: dark) { 685 743 color: var(--color-zinc-500); 686 744 } 687 745 } 688 746 } 689 - .htmx-request.htmx-indicator { 690 - display: inline; 691 - } 692 - .htmx-indicator { 693 - display: none; 694 - } 695 - .htmx-request #submit-button { 696 - opacity: 0.5; 697 - pointer-events: none; 698 - } 699 747 @property --tw-space-y-reverse { 700 748 syntax: "*"; 701 749 inherits: false; ··· 715 763 syntax: "*"; 716 764 inherits: false; 717 765 } 766 + @property --tw-blur { 767 + syntax: "*"; 768 + inherits: false; 769 + } 770 + @property --tw-brightness { 771 + syntax: "*"; 772 + inherits: false; 773 + } 774 + @property --tw-contrast { 775 + syntax: "*"; 776 + inherits: false; 777 + } 778 + @property --tw-grayscale { 779 + syntax: "*"; 780 + inherits: false; 781 + } 782 + @property --tw-hue-rotate { 783 + syntax: "*"; 784 + inherits: false; 785 + } 786 + @property --tw-invert { 787 + syntax: "*"; 788 + inherits: false; 789 + } 790 + @property --tw-opacity { 791 + syntax: "*"; 792 + inherits: false; 793 + } 794 + @property --tw-saturate { 795 + syntax: "*"; 796 + inherits: false; 797 + } 798 + @property --tw-sepia { 799 + syntax: "*"; 800 + inherits: false; 801 + } 802 + @property --tw-drop-shadow { 803 + syntax: "*"; 804 + inherits: false; 805 + } 806 + @property --tw-drop-shadow-color { 807 + syntax: "*"; 808 + inherits: false; 809 + } 810 + @property --tw-drop-shadow-alpha { 811 + syntax: "<percentage>"; 812 + inherits: false; 813 + initial-value: 100%; 814 + } 815 + @property --tw-drop-shadow-size { 816 + syntax: "*"; 817 + inherits: false; 818 + } 718 819 @property --tw-shadow { 719 820 syntax: "*"; 720 821 inherits: false; ··· 787 888 --tw-space-x-reverse: 0; 788 889 --tw-border-style: solid; 789 890 --tw-font-weight: initial; 891 + --tw-blur: initial; 892 + --tw-brightness: initial; 893 + --tw-contrast: initial; 894 + --tw-grayscale: initial; 895 + --tw-hue-rotate: initial; 896 + --tw-invert: initial; 897 + --tw-opacity: initial; 898 + --tw-saturate: initial; 899 + --tw-sepia: initial; 900 + --tw-drop-shadow: initial; 901 + --tw-drop-shadow-color: initial; 902 + --tw-drop-shadow-alpha: 100%; 903 + --tw-drop-shadow-size: initial; 790 904 --tw-shadow: 0 0 #0000; 791 905 --tw-shadow-color: initial; 792 906 --tw-shadow-alpha: 100%;