forked from pdsls.dev/pdsls
atproto explorer

Compare changes

Choose any two refs to compare.

+4 -3
index.html
··· 11 11 <meta property="description" content="Browse the public data on atproto" /> 12 12 <link rel="manifest" href="/manifest.json" /> 13 13 <title>PDSls</title> 14 - <link rel="preconnect" href="https://rsms.me/" /> 15 - <link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> 16 14 <link rel="preconnect" href="https://fonts.bunny.net" /> 17 15 <link href="https://fonts.bunny.net/css?family=roboto-mono:400" rel="stylesheet" /> 18 16 <link href="https://fonts.cdnfonts.com/css/pecita" rel="stylesheet" /> ··· 26 24 <script src="/src/index.tsx" type="module"></script> 27 25 </head> 28 26 29 - <body id="root" class="dark:bg-dark-500 min-h-screen bg-neutral-100"> 27 + <body 28 + id="root" 29 + class="dark:bg-dark-500 min-h-screen bg-neutral-100 text-neutral-900 dark:text-neutral-200" 30 + > 30 31 <noscript>You need to enable JavaScript to run this app.</noscript> 31 32 </body> 32 33 </html>
+18 -17
package.json
··· 9 9 "serve": "vite preview" 10 10 }, 11 11 "devDependencies": { 12 - "@iconify-json/lucide": "^1.2.77", 12 + "@iconify-json/lucide": "^1.2.81", 13 13 "@iconify/tailwind4": "^1.2.0", 14 - "@tailwindcss/vite": "^4.1.17", 15 - "prettier": "^3.7.3", 14 + "@tailwindcss/vite": "^4.1.18", 15 + "prettier": "^3.7.4", 16 16 "prettier-plugin-organize-imports": "^4.3.0", 17 - "prettier-plugin-tailwindcss": "^0.7.1", 18 - "tailwindcss": "^4.1.17", 17 + "prettier-plugin-tailwindcss": "^0.7.2", 18 + "tailwindcss": "^4.1.18", 19 19 "typescript": "^5.9.3", 20 - "vite": "^7.2.4", 20 + "vite": "^7.2.7", 21 21 "vite-plugin-solid": "^2.11.10" 22 22 }, 23 23 "dependencies": { 24 24 "@atcute/atproto": "^3.1.9", 25 - "@atcute/bluesky": "^3.2.11", 26 - "@atcute/client": "^4.1.0", 27 - "@atcute/crypto": "^2.2.6", 25 + "@atcute/bluesky": "^3.2.14", 26 + "@atcute/client": "^4.1.1", 27 + "@atcute/crypto": "^2.3.0", 28 28 "@atcute/did-plc": "^0.2.0", 29 29 "@atcute/identity": "^1.1.3", 30 - "@atcute/identity-resolver": "^1.1.4", 31 - "@atcute/leaflet": "^1.0.12", 32 - "@atcute/lexicon-doc": "^2.0.4", 30 + "@atcute/identity-resolver": "^1.2.0", 31 + "@atcute/leaflet": "^1.0.14", 32 + "@atcute/lexicon-doc": "^2.0.5", 33 33 "@atcute/lexicon-resolver": "^0.1.5", 34 34 "@atcute/lexicons": "^1.2.5", 35 - "@atcute/oauth-browser-client": "^2.0.1", 35 + "@atcute/multibase": "^1.1.6", 36 + "@atcute/oauth-browser-client": "^2.0.3", 36 37 "@atcute/repo": "^0.1.0", 37 - "@atcute/tangled": "^1.0.12", 38 + "@atcute/tangled": "^1.0.13", 38 39 "@atcute/tid": "^1.0.3", 39 40 "@codemirror/commands": "^6.10.0", 40 41 "@codemirror/lang-json": "^6.0.2", 41 42 "@codemirror/lint": "^6.9.2", 42 43 "@codemirror/state": "^6.5.2", 43 - "@codemirror/view": "^6.38.8", 44 - "@fsegurai/codemirror-theme-basic-dark": "^6.2.2", 45 - "@fsegurai/codemirror-theme-basic-light": "^6.2.2", 44 + "@codemirror/view": "^6.39.4", 45 + "@fsegurai/codemirror-theme-basic-dark": "^6.2.3", 46 + "@fsegurai/codemirror-theme-basic-light": "^6.2.3", 46 47 "@mary/exif-rm": "jsr:^0.2.2", 47 48 "@skyware/firehose": "^0.5.2", 48 49 "@solidjs/meta": "^0.29.4",
+223 -219
pnpm-lock.yaml
··· 12 12 specifier: ^3.1.9 13 13 version: 3.1.9 14 14 '@atcute/bluesky': 15 - specifier: ^3.2.11 16 - version: 3.2.11 15 + specifier: ^3.2.14 16 + version: 3.2.14 17 17 '@atcute/client': 18 - specifier: ^4.1.0 19 - version: 4.1.0 18 + specifier: ^4.1.1 19 + version: 4.1.1 20 20 '@atcute/crypto': 21 - specifier: ^2.2.6 22 - version: 2.2.6 21 + specifier: ^2.3.0 22 + version: 2.3.0 23 23 '@atcute/did-plc': 24 24 specifier: ^0.2.0 25 25 version: 0.2.0 ··· 27 27 specifier: ^1.1.3 28 28 version: 1.1.3 29 29 '@atcute/identity-resolver': 30 - specifier: ^1.1.4 31 - version: 1.1.4(@atcute/identity@1.1.3) 30 + specifier: ^1.2.0 31 + version: 1.2.0(@atcute/identity@1.1.3) 32 32 '@atcute/leaflet': 33 - specifier: ^1.0.12 34 - version: 1.0.12 33 + specifier: ^1.0.14 34 + version: 1.0.14 35 35 '@atcute/lexicon-doc': 36 - specifier: ^2.0.4 37 - version: 2.0.4 36 + specifier: ^2.0.5 37 + version: 2.0.5 38 38 '@atcute/lexicon-resolver': 39 39 specifier: ^0.1.5 40 - version: 0.1.5(@atcute/identity-resolver@1.1.4(@atcute/identity@1.1.3))(@atcute/identity@1.1.3) 40 + version: 0.1.5(@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3))(@atcute/identity@1.1.3) 41 41 '@atcute/lexicons': 42 42 specifier: ^1.2.5 43 43 version: 1.2.5 44 + '@atcute/multibase': 45 + specifier: ^1.1.6 46 + version: 1.1.6 44 47 '@atcute/oauth-browser-client': 45 - specifier: ^2.0.1 46 - version: 2.0.1 48 + specifier: ^2.0.3 49 + version: 2.0.3(@atcute/identity@1.1.3) 47 50 '@atcute/repo': 48 51 specifier: ^0.1.0 49 52 version: 0.1.0 50 53 '@atcute/tangled': 51 - specifier: ^1.0.12 52 - version: 1.0.12 54 + specifier: ^1.0.13 55 + version: 1.0.13 53 56 '@atcute/tid': 54 57 specifier: ^1.0.3 55 58 version: 1.0.3 ··· 66 69 specifier: ^6.5.2 67 70 version: 6.5.2 68 71 '@codemirror/view': 69 - specifier: ^6.38.8 70 - version: 6.38.8 72 + specifier: ^6.39.4 73 + version: 6.39.4 71 74 '@fsegurai/codemirror-theme-basic-dark': 72 - specifier: ^6.2.2 73 - version: 6.2.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/highlight@1.2.3) 75 + specifier: ^6.2.3 76 + version: 6.2.3(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.39.4)(@lezer/highlight@1.2.3) 74 77 '@fsegurai/codemirror-theme-basic-light': 75 - specifier: ^6.2.2 76 - version: 6.2.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/highlight@1.2.3) 78 + specifier: ^6.2.3 79 + version: 6.2.3(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.39.4)(@lezer/highlight@1.2.3) 77 80 '@mary/exif-rm': 78 81 specifier: jsr:^0.2.2 79 82 version: '@jsr/mary__exif-rm@0.2.2' ··· 94 97 version: 1.9.10 95 98 devDependencies: 96 99 '@iconify-json/lucide': 97 - specifier: ^1.2.77 98 - version: 1.2.77 100 + specifier: ^1.2.81 101 + version: 1.2.81 99 102 '@iconify/tailwind4': 100 103 specifier: ^1.2.0 101 - version: 1.2.0(tailwindcss@4.1.17) 104 + version: 1.2.0(tailwindcss@4.1.18) 102 105 '@tailwindcss/vite': 103 - specifier: ^4.1.17 104 - version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 106 + specifier: ^4.1.18 107 + version: 4.1.18(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 105 108 prettier: 106 - specifier: ^3.7.3 107 - version: 3.7.3 109 + specifier: ^3.7.4 110 + version: 3.7.4 108 111 prettier-plugin-organize-imports: 109 112 specifier: ^4.3.0 110 - version: 4.3.0(prettier@3.7.3)(typescript@5.9.3) 113 + version: 4.3.0(prettier@3.7.4)(typescript@5.9.3) 111 114 prettier-plugin-tailwindcss: 112 - specifier: ^0.7.1 113 - version: 0.7.1(prettier-plugin-organize-imports@4.3.0(prettier@3.7.3)(typescript@5.9.3))(prettier@3.7.3) 115 + specifier: ^0.7.2 116 + version: 0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.7.4)(typescript@5.9.3))(prettier@3.7.4) 114 117 tailwindcss: 115 - specifier: ^4.1.17 116 - version: 4.1.17 118 + specifier: ^4.1.18 119 + version: 4.1.18 117 120 typescript: 118 121 specifier: ^5.9.3 119 122 version: 5.9.3 120 123 vite: 121 - specifier: ^7.2.4 122 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 124 + specifier: ^7.2.7 125 + version: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 123 126 vite-plugin-solid: 124 127 specifier: ^2.11.10 125 - version: 2.11.10(solid-js@1.9.10)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 128 + version: 2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 126 129 127 130 packages: 128 131 ··· 132 135 '@atcute/atproto@3.1.9': 133 136 resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==} 134 137 135 - '@atcute/bluesky@3.2.11': 136 - resolution: {integrity: sha512-AboS6y4t+zaxIq7E4noue10csSpIuk/Uwo30/l6GgGBDPXrd7STw8Yb5nGZQP+TdG/uC8/c2mm7UnY65SDOh6A==} 138 + '@atcute/bluesky@3.2.14': 139 + resolution: {integrity: sha512-XlVuF55AYIyplmKvlGLlj+cUvk9ggxNRPczkTPIY991xJ4qDxDHpBJ39ekAV4dWcuBoRo2o9JynzpafPu2ljDA==} 137 140 138 141 '@atcute/car@3.1.3': 139 142 resolution: {integrity: sha512-WJ13bAEt7TjDMVi09ubjLtvhdljbWInGm9Kfy7Y6NhrmiyC/aZYaA/zHX/bHI6xv1c/h3SQduWqxOr4ae49eqA==} ··· 147 150 '@atcute/cid@2.2.6': 148 151 resolution: {integrity: sha512-bTAHHbJ24p+E//V4KCS4xdmd39o211jJswvqQOevj7vk+5IYcgDLx1ryZWZ1sEPOo9x875li/kj5gpKL14RDwQ==} 149 152 150 - '@atcute/client@4.1.0': 151 - resolution: {integrity: sha512-AYhSu3RSDA2VDkVGOmad320NRbUUUf5pCFWJcOzlk25YC/4kyzmMFfpzhf1jjjEcY+anNBXGGhav/kKB1evggQ==} 153 + '@atcute/client@4.1.1': 154 + resolution: {integrity: sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==} 152 155 153 - '@atcute/crypto@2.2.6': 154 - resolution: {integrity: sha512-vkuexF+kmrKE1/Uqzub99Qi4QpnxA2jbu60E6PTgL4XypELQ6rb59MB/J1VbY2gs0kd3ET7+L3+NWpKD5nXyfA==} 156 + '@atcute/crypto@2.3.0': 157 + resolution: {integrity: sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==} 155 158 156 159 '@atcute/did-plc@0.2.0': 157 160 resolution: {integrity: sha512-1sGek8GRM/Ph7nLVRREm8FqM7g4shGckItvdVwJcRbUa8Rh0zOsXQa0QyYWAC0k40BhkqO9FwKXhJEaXCmF5oQ==} 158 161 159 - '@atcute/identity-resolver@1.1.4': 160 - resolution: {integrity: sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==} 162 + '@atcute/identity-resolver@1.2.0': 163 + resolution: {integrity: sha512-5UbSJfdV3JIkF8ksXz7g4nKBWasf2wROvzM66cfvTIWydWFO6/oS1KZd+zo9Eokje5Scf5+jsY9ZfgVARLepXg==} 161 164 peerDependencies: 162 165 '@atcute/identity': ^1.0.0 163 166 164 167 '@atcute/identity@1.1.3': 165 168 resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 166 169 167 - '@atcute/leaflet@1.0.12': 168 - resolution: {integrity: sha512-T5laBTl8vwzy0eZXBy07IQSjsLqhbZmRJsffnNQ6XMSc+lnCZ/NHfuKy8TNJbDU6dc26Z7o5l0ELfWz5QESo+w==} 170 + '@atcute/leaflet@1.0.14': 171 + resolution: {integrity: sha512-TWbtB7b73GChBaYwfd7aWFyGVObZ/DqrRtwkpWGm1GO8zZmQ9eJyKDUnXim7NOAs2hmKQ1u2wk2AM4AYzkF5Gg==} 169 172 170 - '@atcute/lexicon-doc@2.0.4': 171 - resolution: {integrity: sha512-YfwlYFoYiBvRIYG0I1zsINCTFugFtS8l67uT3nQ04zdKVflzdg8uUj8cNZYRNY1V7okoOPdikhR4kPFhYGyemw==} 173 + '@atcute/lexicon-doc@2.0.5': 174 + resolution: {integrity: sha512-fNCp94ehGjWFZMIqP6pWD1F9MOJogNCyqsaMVZluPSIclZ+lDL528iXB56aW4u0eSiD6Y9WJB1OI/lElG39cSA==} 172 175 173 176 '@atcute/lexicon-resolver@0.1.5': 174 177 resolution: {integrity: sha512-0bx1/zdMQPuxvRcHW6ykAxRxktC2rEZLoAVSFoLSWDAA92Tf09F9QPK5wgXSF4MNODm1dvzMEdWSMIvlg8sr3A==} ··· 185 188 '@atcute/multibase@1.1.6': 186 189 resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==} 187 190 188 - '@atcute/oauth-browser-client@2.0.1': 189 - resolution: {integrity: sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg==} 191 + '@atcute/oauth-browser-client@2.0.3': 192 + resolution: {integrity: sha512-rzUjwhjE4LRRKdQnCFQag/zXRZMEAB1hhBoLfnoQuHwWbmDUCL7fzwC3jRhDPp3om8XaYNDj8a/iqRip0wRqoQ==} 190 193 191 194 '@atcute/repo@0.1.0': 192 195 resolution: {integrity: sha512-INiYAuma8dydBu7cqd2WVpcXh3mzhIepYBUqFWAK5MqMulPRLTRCc/9GW3G9pxYrOdlvLCVamG2Jf8XK0nuFEw==} 193 196 194 - '@atcute/tangled@1.0.12': 195 - resolution: {integrity: sha512-JKA5sOhd8SLhDFhY+PKHqLLytQBBKSiwcaEzfYUJBeyfvqXFPNNAwvRbe3VST4IQ3izoOu3O0R9/b1mjL45UzA==} 197 + '@atcute/tangled@1.0.13': 198 + resolution: {integrity: sha512-K95jmjDXl/f1FFzOJkk07ibNbFsPmn64sdrMACxQmUibO9WcfSjzjZLPXuH6WHFnCNtIBG3x1FQ7ndQgLoZAmw==} 196 199 197 200 '@atcute/tid@1.0.3': 198 201 resolution: {integrity: sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==} 199 202 200 - '@atcute/uint8array@1.0.5': 201 - resolution: {integrity: sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q==} 203 + '@atcute/uint8array@1.0.6': 204 + resolution: {integrity: sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==} 202 205 203 206 '@atcute/util-fetch@1.0.4': 204 207 resolution: {integrity: sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==} ··· 312 315 '@codemirror/state@6.5.2': 313 316 resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} 314 317 315 - '@codemirror/view@6.38.8': 316 - resolution: {integrity: sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==} 318 + '@codemirror/view@6.39.4': 319 + resolution: {integrity: sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==} 317 320 318 321 '@cyberalien/svg-utils@1.0.11': 319 322 resolution: {integrity: sha512-qEE9mnyI+avfGT3emKuRs3ucYkITeaV0Xi7VlYN41f+uGnZBecQP3jwz/AF437H9J4Q7qPClHKm4NiTYpNE6hA==} ··· 618 621 cpu: [x64] 619 622 os: [win32] 620 623 621 - '@fsegurai/codemirror-theme-basic-dark@6.2.2': 622 - resolution: {integrity: sha512-cVK4VheF7ZkuV0sfy20lmH2S7Q2xIfKoqN2HdU5rpGH8mZM2LVG9Tl+oHT0XNPpsWFqNAAKLzjYFw0IPX95Biw==} 624 + '@fsegurai/codemirror-theme-basic-dark@6.2.3': 625 + resolution: {integrity: sha512-08d09Yn9Ic8mjCzrBQQhtws/HM+8B00bRV9FqW+GaIQwSOFmn17FsvzuLJQyervcKAkTzmKaLPjp2D3Y+2K8EQ==} 623 626 peerDependencies: 624 627 '@codemirror/language': ^6.0.0 625 628 '@codemirror/state': ^6.0.0 626 629 '@codemirror/view': ^6.0.0 627 630 '@lezer/highlight': ^1.0.0 628 631 629 - '@fsegurai/codemirror-theme-basic-light@6.2.2': 630 - resolution: {integrity: sha512-zFtJ6VwwEeZ/HAXMYdcJz6+DdW1aQkngFwbD3diku79cctpTglCWH49KRFO8Mifjzwylsynm7dLyOUnGhIu0NQ==} 632 + '@fsegurai/codemirror-theme-basic-light@6.2.3': 633 + resolution: {integrity: sha512-rkHCj1U3OwNAqLLi2xti47u3Fq6gDiSEKmQsAOwIADJKnnwU2LeAwCPqSEa7sUVlavFusjDvt5L/SmGjb10vWg==} 631 634 peerDependencies: 632 635 '@codemirror/language': ^6.0.0 633 636 '@codemirror/state': ^6.0.0 634 637 '@codemirror/view': ^6.0.0 635 638 '@lezer/highlight': ^1.0.0 636 639 637 - '@iconify-json/lucide@1.2.77': 638 - resolution: {integrity: sha512-FF3Z+np6Ksb0MaoQymhCHZ4xs5Oo8992Fw7By7bCgVCbBCClYV3wxpF8KzsI1FlxHD4ZXR42NVmXuqdW8YQGgA==} 640 + '@iconify-json/lucide@1.2.81': 641 + resolution: {integrity: sha512-6Kz/+SEuD5bkg0KImi0yFem9l6njKp4e1qF1LpQbgRfk7ngsJR/qjlB4y5rM8N1iKiDR/p19cqhmwZxyCWek+w==} 639 642 640 643 '@iconify/tailwind4@1.2.0': 641 644 resolution: {integrity: sha512-+t7XqfojOB0zzZdd8gV7IQZGq1AaIHTlsxMVzagxYR0hAlJCLUD63o3iSlNKRMH3ZR7gZ8y5c9dJ7J431avRbA==} ··· 679 682 '@lezer/json@1.0.3': 680 683 resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} 681 684 682 - '@lezer/lr@1.4.4': 683 - resolution: {integrity: sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==} 685 + '@lezer/lr@1.4.5': 686 + resolution: {integrity: sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==} 684 687 685 688 '@marijn/find-cluster-break@1.0.2': 686 689 resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} ··· 814 817 '@standard-schema/spec@1.0.0': 815 818 resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} 816 819 817 - '@tailwindcss/node@4.1.17': 818 - resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} 820 + '@tailwindcss/node@4.1.18': 821 + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} 819 822 820 - '@tailwindcss/oxide-android-arm64@4.1.17': 821 - resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==} 823 + '@tailwindcss/oxide-android-arm64@4.1.18': 824 + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} 822 825 engines: {node: '>= 10'} 823 826 cpu: [arm64] 824 827 os: [android] 825 828 826 - '@tailwindcss/oxide-darwin-arm64@4.1.17': 827 - resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==} 829 + '@tailwindcss/oxide-darwin-arm64@4.1.18': 830 + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} 828 831 engines: {node: '>= 10'} 829 832 cpu: [arm64] 830 833 os: [darwin] 831 834 832 - '@tailwindcss/oxide-darwin-x64@4.1.17': 833 - resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==} 835 + '@tailwindcss/oxide-darwin-x64@4.1.18': 836 + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} 834 837 engines: {node: '>= 10'} 835 838 cpu: [x64] 836 839 os: [darwin] 837 840 838 - '@tailwindcss/oxide-freebsd-x64@4.1.17': 839 - resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==} 841 + '@tailwindcss/oxide-freebsd-x64@4.1.18': 842 + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} 840 843 engines: {node: '>= 10'} 841 844 cpu: [x64] 842 845 os: [freebsd] 843 846 844 - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': 845 - resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==} 847 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': 848 + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} 846 849 engines: {node: '>= 10'} 847 850 cpu: [arm] 848 851 os: [linux] 849 852 850 - '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': 851 - resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==} 853 + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': 854 + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} 852 855 engines: {node: '>= 10'} 853 856 cpu: [arm64] 854 857 os: [linux] 855 858 856 - '@tailwindcss/oxide-linux-arm64-musl@4.1.17': 857 - resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} 859 + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': 860 + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} 858 861 engines: {node: '>= 10'} 859 862 cpu: [arm64] 860 863 os: [linux] 861 864 862 - '@tailwindcss/oxide-linux-x64-gnu@4.1.17': 863 - resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} 865 + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': 866 + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} 864 867 engines: {node: '>= 10'} 865 868 cpu: [x64] 866 869 os: [linux] 867 870 868 - '@tailwindcss/oxide-linux-x64-musl@4.1.17': 869 - resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} 871 + '@tailwindcss/oxide-linux-x64-musl@4.1.18': 872 + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} 870 873 engines: {node: '>= 10'} 871 874 cpu: [x64] 872 875 os: [linux] 873 876 874 - '@tailwindcss/oxide-wasm32-wasi@4.1.17': 875 - resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} 877 + '@tailwindcss/oxide-wasm32-wasi@4.1.18': 878 + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} 876 879 engines: {node: '>=14.0.0'} 877 880 cpu: [wasm32] 878 881 bundledDependencies: ··· 883 886 - '@emnapi/wasi-threads' 884 887 - tslib 885 888 886 - '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': 887 - resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==} 889 + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': 890 + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} 888 891 engines: {node: '>= 10'} 889 892 cpu: [arm64] 890 893 os: [win32] 891 894 892 - '@tailwindcss/oxide-win32-x64-msvc@4.1.17': 893 - resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==} 895 + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': 896 + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} 894 897 engines: {node: '>= 10'} 895 898 cpu: [x64] 896 899 os: [win32] 897 900 898 - '@tailwindcss/oxide@4.1.17': 899 - resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==} 901 + '@tailwindcss/oxide@4.1.18': 902 + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} 900 903 engines: {node: '>= 10'} 901 904 902 - '@tailwindcss/vite@4.1.17': 903 - resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==} 905 + '@tailwindcss/vite@4.1.18': 906 + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} 904 907 peerDependencies: 905 908 vite: ^5.2.0 || ^6 || ^7 906 909 ··· 941 944 solid-js: 942 945 optional: true 943 946 944 - baseline-browser-mapping@2.8.32: 945 - resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} 947 + baseline-browser-mapping@2.9.7: 948 + resolution: {integrity: sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==} 946 949 hasBin: true 947 950 948 951 boolbase@1.0.0: 949 952 resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} 950 953 951 - browserslist@4.28.0: 952 - resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} 954 + browserslist@4.28.1: 955 + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} 953 956 engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} 954 957 hasBin: true 955 958 956 - caniuse-lite@1.0.30001757: 957 - resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} 959 + caniuse-lite@1.0.30001760: 960 + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} 958 961 959 962 codemirror@6.0.2: 960 963 resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} ··· 1020 1023 domutils@3.2.2: 1021 1024 resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 1022 1025 1023 - electron-to-chromium@1.5.262: 1024 - resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} 1026 + electron-to-chromium@1.5.267: 1027 + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} 1025 1028 1026 - enhanced-resolve@5.18.3: 1027 - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} 1029 + enhanced-resolve@5.18.4: 1030 + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} 1028 1031 engines: {node: '>=10.13.0'} 1029 1032 1030 1033 entities@4.5.0: ··· 1252 1255 vue-tsc: 1253 1256 optional: true 1254 1257 1255 - prettier-plugin-tailwindcss@0.7.1: 1256 - resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==} 1258 + prettier-plugin-tailwindcss@0.7.2: 1259 + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} 1257 1260 engines: {node: '>=20.19'} 1258 1261 peerDependencies: 1259 1262 '@ianvs/prettier-plugin-sort-imports': '*' ··· 1307 1310 prettier-plugin-svelte: 1308 1311 optional: true 1309 1312 1310 - prettier@3.7.3: 1311 - resolution: {integrity: sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==} 1313 + prettier@3.7.4: 1314 + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} 1312 1315 engines: {node: '>=14'} 1313 1316 hasBin: true 1314 1317 ··· 1357 1360 engines: {node: '>=16'} 1358 1361 hasBin: true 1359 1362 1360 - tailwindcss@4.1.17: 1361 - resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} 1363 + tailwindcss@4.1.18: 1364 + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} 1362 1365 1363 1366 tapable@2.3.0: 1364 1367 resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} ··· 1388 1391 undici-types@7.16.0: 1389 1392 resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} 1390 1393 1391 - update-browserslist-db@1.1.4: 1392 - resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} 1394 + update-browserslist-db@1.2.2: 1395 + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} 1393 1396 hasBin: true 1394 1397 peerDependencies: 1395 1398 browserslist: '>= 4.21.0' ··· 1404 1407 '@testing-library/jest-dom': 1405 1408 optional: true 1406 1409 1407 - vite@7.2.4: 1408 - resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} 1410 + vite@7.2.7: 1411 + resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==} 1409 1412 engines: {node: ^20.19.0 || >=22.12.0} 1410 1413 hasBin: true 1411 1414 peerDependencies: ··· 1473 1476 dependencies: 1474 1477 '@atcute/lexicons': 1.2.5 1475 1478 1476 - '@atcute/bluesky@3.2.11': 1479 + '@atcute/bluesky@3.2.14': 1477 1480 dependencies: 1478 1481 '@atcute/atproto': 3.1.9 1479 1482 '@atcute/lexicons': 1.2.5 ··· 1482 1485 dependencies: 1483 1486 '@atcute/cbor': 2.2.8 1484 1487 '@atcute/cid': 2.2.6 1485 - '@atcute/uint8array': 1.0.5 1488 + '@atcute/uint8array': 1.0.6 1486 1489 '@atcute/varint': 1.0.3 1487 1490 yocto-queue: 1.2.2 1488 1491 ··· 1490 1493 dependencies: 1491 1494 '@atcute/cbor': 2.2.8 1492 1495 '@atcute/cid': 2.2.6 1493 - '@atcute/uint8array': 1.0.5 1496 + '@atcute/uint8array': 1.0.6 1494 1497 '@atcute/varint': 1.0.3 1495 1498 1496 1499 '@atcute/cbor@2.2.8': 1497 1500 dependencies: 1498 1501 '@atcute/cid': 2.2.6 1499 1502 '@atcute/multibase': 1.1.6 1500 - '@atcute/uint8array': 1.0.5 1503 + '@atcute/uint8array': 1.0.6 1501 1504 1502 1505 '@atcute/cid@2.2.6': 1503 1506 dependencies: 1504 1507 '@atcute/multibase': 1.1.6 1505 - '@atcute/uint8array': 1.0.5 1508 + '@atcute/uint8array': 1.0.6 1506 1509 1507 - '@atcute/client@4.1.0': 1510 + '@atcute/client@4.1.1': 1508 1511 dependencies: 1509 1512 '@atcute/identity': 1.1.3 1510 1513 '@atcute/lexicons': 1.2.5 1511 1514 1512 - '@atcute/crypto@2.2.6': 1515 + '@atcute/crypto@2.3.0': 1513 1516 dependencies: 1514 1517 '@atcute/multibase': 1.1.6 1515 - '@atcute/uint8array': 1.0.5 1518 + '@atcute/uint8array': 1.0.6 1516 1519 '@noble/secp256k1': 3.0.0 1517 1520 1518 1521 '@atcute/did-plc@0.2.0': 1519 1522 dependencies: 1520 1523 '@atcute/cbor': 2.2.8 1521 1524 '@atcute/cid': 2.2.6 1522 - '@atcute/crypto': 2.2.6 1525 + '@atcute/crypto': 2.3.0 1523 1526 '@atcute/identity': 1.1.3 1524 1527 '@atcute/lexicons': 1.2.5 1525 1528 '@atcute/multibase': 1.1.6 1526 - '@atcute/uint8array': 1.0.5 1529 + '@atcute/uint8array': 1.0.6 1527 1530 '@badrap/valita': 0.4.6 1528 1531 1529 - '@atcute/identity-resolver@1.1.4(@atcute/identity@1.1.3)': 1532 + '@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3)': 1530 1533 dependencies: 1531 1534 '@atcute/identity': 1.1.3 1532 1535 '@atcute/lexicons': 1.2.5 ··· 1538 1541 '@atcute/lexicons': 1.2.5 1539 1542 '@badrap/valita': 0.4.6 1540 1543 1541 - '@atcute/leaflet@1.0.12': 1544 + '@atcute/leaflet@1.0.14': 1542 1545 dependencies: 1543 1546 '@atcute/atproto': 3.1.9 1544 1547 '@atcute/lexicons': 1.2.5 1545 1548 1546 - '@atcute/lexicon-doc@2.0.4': 1549 + '@atcute/lexicon-doc@2.0.5': 1547 1550 dependencies: 1548 1551 '@atcute/identity': 1.1.3 1549 1552 '@atcute/lexicons': 1.2.5 1550 1553 '@badrap/valita': 0.4.6 1551 1554 1552 - '@atcute/lexicon-resolver@0.1.5(@atcute/identity-resolver@1.1.4(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)': 1555 + '@atcute/lexicon-resolver@0.1.5(@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)': 1553 1556 dependencies: 1554 - '@atcute/crypto': 2.2.6 1557 + '@atcute/crypto': 2.3.0 1555 1558 '@atcute/identity': 1.1.3 1556 - '@atcute/identity-resolver': 1.1.4(@atcute/identity@1.1.3) 1557 - '@atcute/lexicon-doc': 2.0.4 1559 + '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 1560 + '@atcute/lexicon-doc': 2.0.5 1558 1561 '@atcute/lexicons': 1.2.5 1559 1562 '@atcute/repo': 0.1.0 1560 1563 '@atcute/util-fetch': 1.0.4 ··· 1569 1572 dependencies: 1570 1573 '@atcute/cbor': 2.2.8 1571 1574 '@atcute/cid': 2.2.6 1572 - '@atcute/uint8array': 1.0.5 1575 + '@atcute/uint8array': 1.0.6 1573 1576 1574 1577 '@atcute/multibase@1.1.6': 1575 1578 dependencies: 1576 - '@atcute/uint8array': 1.0.5 1579 + '@atcute/uint8array': 1.0.6 1577 1580 1578 - '@atcute/oauth-browser-client@2.0.1': 1581 + '@atcute/oauth-browser-client@2.0.3(@atcute/identity@1.1.3)': 1579 1582 dependencies: 1580 - '@atcute/client': 4.1.0 1581 - '@atcute/identity': 1.1.3 1582 - '@atcute/identity-resolver': 1.1.4(@atcute/identity@1.1.3) 1583 + '@atcute/client': 4.1.1 1584 + '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 1583 1585 '@atcute/lexicons': 1.2.5 1584 1586 '@atcute/multibase': 1.1.6 1585 - '@atcute/uint8array': 1.0.5 1587 + '@atcute/uint8array': 1.0.6 1586 1588 nanoid: 5.1.6 1589 + transitivePeerDependencies: 1590 + - '@atcute/identity' 1587 1591 1588 1592 '@atcute/repo@0.1.0': 1589 1593 dependencies: 1590 1594 '@atcute/car': 5.0.0 1591 1595 '@atcute/cbor': 2.2.8 1592 1596 '@atcute/cid': 2.2.6 1593 - '@atcute/crypto': 2.2.6 1597 + '@atcute/crypto': 2.3.0 1594 1598 '@atcute/lexicons': 1.2.5 1595 1599 '@atcute/mst': 0.1.0 1596 - '@atcute/uint8array': 1.0.5 1600 + '@atcute/uint8array': 1.0.6 1597 1601 1598 - '@atcute/tangled@1.0.12': 1602 + '@atcute/tangled@1.0.13': 1599 1603 dependencies: 1600 1604 '@atcute/atproto': 3.1.9 1601 1605 '@atcute/lexicons': 1.2.5 1602 1606 1603 1607 '@atcute/tid@1.0.3': {} 1604 1608 1605 - '@atcute/uint8array@1.0.5': {} 1609 + '@atcute/uint8array@1.0.6': {} 1606 1610 1607 1611 '@atcute/util-fetch@1.0.4': 1608 1612 dependencies: ··· 1650 1654 dependencies: 1651 1655 '@babel/compat-data': 7.28.5 1652 1656 '@babel/helper-validator-option': 7.27.1 1653 - browserslist: 4.28.0 1657 + browserslist: 4.28.1 1654 1658 lru-cache: 5.1.1 1655 1659 semver: 6.3.1 1656 1660 ··· 1727 1731 dependencies: 1728 1732 '@codemirror/language': 6.11.3 1729 1733 '@codemirror/state': 6.5.2 1730 - '@codemirror/view': 6.38.8 1734 + '@codemirror/view': 6.39.4 1731 1735 '@lezer/common': 1.4.0 1732 1736 1733 1737 '@codemirror/commands@6.10.0': 1734 1738 dependencies: 1735 1739 '@codemirror/language': 6.11.3 1736 1740 '@codemirror/state': 6.5.2 1737 - '@codemirror/view': 6.38.8 1741 + '@codemirror/view': 6.39.4 1738 1742 '@lezer/common': 1.4.0 1739 1743 1740 1744 '@codemirror/lang-json@6.0.2': ··· 1745 1749 '@codemirror/language@6.11.3': 1746 1750 dependencies: 1747 1751 '@codemirror/state': 6.5.2 1748 - '@codemirror/view': 6.38.8 1752 + '@codemirror/view': 6.39.4 1749 1753 '@lezer/common': 1.4.0 1750 1754 '@lezer/highlight': 1.2.3 1751 - '@lezer/lr': 1.4.4 1755 + '@lezer/lr': 1.4.5 1752 1756 style-mod: 4.1.3 1753 1757 1754 1758 '@codemirror/lint@6.9.2': 1755 1759 dependencies: 1756 1760 '@codemirror/state': 6.5.2 1757 - '@codemirror/view': 6.38.8 1761 + '@codemirror/view': 6.39.4 1758 1762 crelt: 1.0.6 1759 1763 1760 1764 '@codemirror/search@6.5.11': 1761 1765 dependencies: 1762 1766 '@codemirror/state': 6.5.2 1763 - '@codemirror/view': 6.38.8 1767 + '@codemirror/view': 6.39.4 1764 1768 crelt: 1.0.6 1765 1769 1766 1770 '@codemirror/state@6.5.2': 1767 1771 dependencies: 1768 1772 '@marijn/find-cluster-break': 1.0.2 1769 1773 1770 - '@codemirror/view@6.38.8': 1774 + '@codemirror/view@6.39.4': 1771 1775 dependencies: 1772 1776 '@codemirror/state': 6.5.2 1773 1777 crelt: 1.0.6 ··· 1928 1932 '@esbuild/win32-x64@0.25.12': 1929 1933 optional: true 1930 1934 1931 - '@fsegurai/codemirror-theme-basic-dark@6.2.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/highlight@1.2.3)': 1935 + '@fsegurai/codemirror-theme-basic-dark@6.2.3(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.39.4)(@lezer/highlight@1.2.3)': 1932 1936 dependencies: 1933 1937 '@codemirror/language': 6.11.3 1934 1938 '@codemirror/state': 6.5.2 1935 - '@codemirror/view': 6.38.8 1939 + '@codemirror/view': 6.39.4 1936 1940 '@lezer/highlight': 1.2.3 1937 1941 1938 - '@fsegurai/codemirror-theme-basic-light@6.2.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)(@lezer/highlight@1.2.3)': 1942 + '@fsegurai/codemirror-theme-basic-light@6.2.3(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.39.4)(@lezer/highlight@1.2.3)': 1939 1943 dependencies: 1940 1944 '@codemirror/language': 6.11.3 1941 1945 '@codemirror/state': 6.5.2 1942 - '@codemirror/view': 6.38.8 1946 + '@codemirror/view': 6.39.4 1943 1947 '@lezer/highlight': 1.2.3 1944 1948 1945 - '@iconify-json/lucide@1.2.77': 1949 + '@iconify-json/lucide@1.2.81': 1946 1950 dependencies: 1947 1951 '@iconify/types': 2.0.0 1948 1952 1949 - '@iconify/tailwind4@1.2.0(tailwindcss@4.1.17)': 1953 + '@iconify/tailwind4@1.2.0(tailwindcss@4.1.18)': 1950 1954 dependencies: 1951 1955 '@iconify/tools': 5.0.0 1952 1956 '@iconify/types': 2.0.0 1953 1957 '@iconify/utils': 3.1.0 1954 - tailwindcss: 4.1.17 1958 + tailwindcss: 4.1.18 1955 1959 1956 1960 '@iconify/tools@5.0.0': 1957 1961 dependencies: ··· 2002 2006 dependencies: 2003 2007 '@lezer/common': 1.4.0 2004 2008 '@lezer/highlight': 1.2.3 2005 - '@lezer/lr': 1.4.4 2009 + '@lezer/lr': 1.4.5 2006 2010 2007 - '@lezer/lr@1.4.4': 2011 + '@lezer/lr@1.4.5': 2008 2012 dependencies: 2009 2013 '@lezer/common': 1.4.0 2010 2014 ··· 2094 2098 2095 2099 '@standard-schema/spec@1.0.0': {} 2096 2100 2097 - '@tailwindcss/node@4.1.17': 2101 + '@tailwindcss/node@4.1.18': 2098 2102 dependencies: 2099 2103 '@jridgewell/remapping': 2.3.5 2100 - enhanced-resolve: 5.18.3 2104 + enhanced-resolve: 5.18.4 2101 2105 jiti: 2.6.1 2102 2106 lightningcss: 1.30.2 2103 2107 magic-string: 0.30.21 2104 2108 source-map-js: 1.2.1 2105 - tailwindcss: 4.1.17 2109 + tailwindcss: 4.1.18 2106 2110 2107 - '@tailwindcss/oxide-android-arm64@4.1.17': 2111 + '@tailwindcss/oxide-android-arm64@4.1.18': 2108 2112 optional: true 2109 2113 2110 - '@tailwindcss/oxide-darwin-arm64@4.1.17': 2114 + '@tailwindcss/oxide-darwin-arm64@4.1.18': 2111 2115 optional: true 2112 2116 2113 - '@tailwindcss/oxide-darwin-x64@4.1.17': 2117 + '@tailwindcss/oxide-darwin-x64@4.1.18': 2114 2118 optional: true 2115 2119 2116 - '@tailwindcss/oxide-freebsd-x64@4.1.17': 2120 + '@tailwindcss/oxide-freebsd-x64@4.1.18': 2117 2121 optional: true 2118 2122 2119 - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': 2123 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': 2120 2124 optional: true 2121 2125 2122 - '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': 2126 + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': 2123 2127 optional: true 2124 2128 2125 - '@tailwindcss/oxide-linux-arm64-musl@4.1.17': 2129 + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': 2126 2130 optional: true 2127 2131 2128 - '@tailwindcss/oxide-linux-x64-gnu@4.1.17': 2132 + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': 2129 2133 optional: true 2130 2134 2131 - '@tailwindcss/oxide-linux-x64-musl@4.1.17': 2135 + '@tailwindcss/oxide-linux-x64-musl@4.1.18': 2132 2136 optional: true 2133 2137 2134 - '@tailwindcss/oxide-wasm32-wasi@4.1.17': 2138 + '@tailwindcss/oxide-wasm32-wasi@4.1.18': 2135 2139 optional: true 2136 2140 2137 - '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': 2141 + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': 2138 2142 optional: true 2139 2143 2140 - '@tailwindcss/oxide-win32-x64-msvc@4.1.17': 2144 + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': 2141 2145 optional: true 2142 2146 2143 - '@tailwindcss/oxide@4.1.17': 2147 + '@tailwindcss/oxide@4.1.18': 2144 2148 optionalDependencies: 2145 - '@tailwindcss/oxide-android-arm64': 4.1.17 2146 - '@tailwindcss/oxide-darwin-arm64': 4.1.17 2147 - '@tailwindcss/oxide-darwin-x64': 4.1.17 2148 - '@tailwindcss/oxide-freebsd-x64': 4.1.17 2149 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 2150 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 2151 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 2152 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 2153 - '@tailwindcss/oxide-linux-x64-musl': 4.1.17 2154 - '@tailwindcss/oxide-wasm32-wasi': 4.1.17 2155 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 2156 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 2149 + '@tailwindcss/oxide-android-arm64': 4.1.18 2150 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 2151 + '@tailwindcss/oxide-darwin-x64': 4.1.18 2152 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 2153 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 2154 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 2155 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 2156 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 2157 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 2158 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 2159 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 2160 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 2157 2161 2158 - '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2))': 2162 + '@tailwindcss/vite@4.1.18(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2))': 2159 2163 dependencies: 2160 - '@tailwindcss/node': 4.1.17 2161 - '@tailwindcss/oxide': 4.1.17 2162 - tailwindcss: 4.1.17 2163 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2164 + '@tailwindcss/node': 4.1.18 2165 + '@tailwindcss/oxide': 4.1.18 2166 + tailwindcss: 4.1.18 2167 + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2164 2168 2165 2169 '@types/babel__core@7.20.5': 2166 2170 dependencies: ··· 2208 2212 optionalDependencies: 2209 2213 solid-js: 1.9.10 2210 2214 2211 - baseline-browser-mapping@2.8.32: {} 2215 + baseline-browser-mapping@2.9.7: {} 2212 2216 2213 2217 boolbase@1.0.0: {} 2214 2218 2215 - browserslist@4.28.0: 2219 + browserslist@4.28.1: 2216 2220 dependencies: 2217 - baseline-browser-mapping: 2.8.32 2218 - caniuse-lite: 1.0.30001757 2219 - electron-to-chromium: 1.5.262 2221 + baseline-browser-mapping: 2.9.7 2222 + caniuse-lite: 1.0.30001760 2223 + electron-to-chromium: 1.5.267 2220 2224 node-releases: 2.0.27 2221 - update-browserslist-db: 1.1.4(browserslist@4.28.0) 2225 + update-browserslist-db: 1.2.2(browserslist@4.28.1) 2222 2226 2223 - caniuse-lite@1.0.30001757: {} 2227 + caniuse-lite@1.0.30001760: {} 2224 2228 2225 2229 codemirror@6.0.2: 2226 2230 dependencies: ··· 2230 2234 '@codemirror/lint': 6.9.2 2231 2235 '@codemirror/search': 6.5.11 2232 2236 '@codemirror/state': 6.5.2 2233 - '@codemirror/view': 6.38.8 2237 + '@codemirror/view': 6.39.4 2234 2238 2235 2239 commander@11.1.0: {} 2236 2240 ··· 2290 2294 domelementtype: 2.3.0 2291 2295 domhandler: 5.0.3 2292 2296 2293 - electron-to-chromium@1.5.262: {} 2297 + electron-to-chromium@1.5.267: {} 2294 2298 2295 - enhanced-resolve@5.18.3: 2299 + enhanced-resolve@5.18.4: 2296 2300 dependencies: 2297 2301 graceful-fs: 4.2.11 2298 2302 tapable: 2.3.0 ··· 2504 2508 picocolors: 1.1.1 2505 2509 source-map-js: 1.2.1 2506 2510 2507 - prettier-plugin-organize-imports@4.3.0(prettier@3.7.3)(typescript@5.9.3): 2511 + prettier-plugin-organize-imports@4.3.0(prettier@3.7.4)(typescript@5.9.3): 2508 2512 dependencies: 2509 - prettier: 3.7.3 2513 + prettier: 3.7.4 2510 2514 typescript: 5.9.3 2511 2515 2512 - prettier-plugin-tailwindcss@0.7.1(prettier-plugin-organize-imports@4.3.0(prettier@3.7.3)(typescript@5.9.3))(prettier@3.7.3): 2516 + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.7.4)(typescript@5.9.3))(prettier@3.7.4): 2513 2517 dependencies: 2514 - prettier: 3.7.3 2518 + prettier: 3.7.4 2515 2519 optionalDependencies: 2516 - prettier-plugin-organize-imports: 4.3.0(prettier@3.7.3)(typescript@5.9.3) 2520 + prettier-plugin-organize-imports: 4.3.0(prettier@3.7.4)(typescript@5.9.3) 2517 2521 2518 - prettier@3.7.3: {} 2522 + prettier@3.7.4: {} 2519 2523 2520 2524 resolve-pkg-maps@1.0.0: 2521 2525 optional: true ··· 2587 2591 picocolors: 1.1.1 2588 2592 sax: 1.4.3 2589 2593 2590 - tailwindcss@4.1.17: {} 2594 + tailwindcss@4.1.18: {} 2591 2595 2592 2596 tapable@2.3.0: {} 2593 2597 ··· 2613 2617 undici-types@7.16.0: 2614 2618 optional: true 2615 2619 2616 - update-browserslist-db@1.1.4(browserslist@4.28.0): 2620 + update-browserslist-db@1.2.2(browserslist@4.28.1): 2617 2621 dependencies: 2618 - browserslist: 4.28.0 2622 + browserslist: 4.28.1 2619 2623 escalade: 3.2.0 2620 2624 picocolors: 1.1.1 2621 2625 2622 - vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2626 + vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2623 2627 dependencies: 2624 2628 '@babel/core': 7.28.5 2625 2629 '@types/babel__core': 7.20.5 ··· 2627 2631 merge-anything: 5.1.7 2628 2632 solid-js: 1.9.10 2629 2633 solid-refresh: 0.6.3(solid-js@1.9.10) 2630 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2631 - vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 2634 + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2635 + vitefu: 1.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 2632 2636 transitivePeerDependencies: 2633 2637 - supports-color 2634 2638 2635 - vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2): 2639 + vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2): 2636 2640 dependencies: 2637 2641 esbuild: 0.25.12 2638 2642 fdir: 6.5.0(picomatch@4.0.3) ··· 2647 2651 lightningcss: 1.30.2 2648 2652 tsx: 4.19.2 2649 2653 2650 - vitefu@1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2654 + vitefu@1.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2651 2655 optionalDependencies: 2652 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2656 + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2653 2657 2654 2658 w3c-keyname@2.2.8: {} 2655 2659
public/favicon.ico

This is a binary file and will not be displayed.

public/fonts/Figtree[wght].woff2

This is a binary file and will not be displayed.

+1 -1
public/oauth-client-metadata.json
··· 4 4 "client_uri": "https://pdsls.dev", 5 5 "logo_uri": "https://pdsls.dev/favicon.ico", 6 6 "redirect_uris": ["https://pdsls.dev/"], 7 - "scope": "atproto transition:generic", 7 + "scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 8 8 "grant_types": ["authorization_code", "refresh_token"], 9 9 "response_types": ["code"], 10 10 "token_endpoint_auth_method": "none",
+194
src/auth/account.tsx
··· 1 + import { Did } from "@atcute/lexicons"; 2 + import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 + import { A } from "@solidjs/router"; 4 + import { createSignal, For, onMount, Show } from "solid-js"; 5 + import { createStore, produce } from "solid-js/store"; 6 + import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 + import { Modal } from "../components/modal.jsx"; 8 + import { Login } from "./login.jsx"; 9 + import { useOAuthScopeFlow } from "./scope-flow.js"; 10 + import { ScopeSelector } from "./scope-selector.jsx"; 11 + import { parseScopeString } from "./scope-utils.js"; 12 + import { 13 + getAvatar, 14 + loadHandleForSession, 15 + loadSessionsFromStorage, 16 + resumeSession, 17 + retrieveSession, 18 + saveSessionToStorage, 19 + } from "./session-manager.js"; 20 + import { agent, sessions, setAgent, setSessions } from "./state.js"; 21 + 22 + const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 + const removeSession = async (did: Did) => { 24 + const currentSession = agent()?.sub; 25 + try { 26 + const session = await getSession(did, { allowStale: true }); 27 + const agent = new OAuthUserAgent(session); 28 + await agent.signOut(); 29 + } catch { 30 + deleteStoredSession(did); 31 + } 32 + setSessions( 33 + produce((accs) => { 34 + delete accs[did]; 35 + }), 36 + ); 37 + saveSessionToStorage(sessions); 38 + if (currentSession === did) setAgent(undefined); 39 + }; 40 + 41 + return ( 42 + <MenuProvider> 43 + <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2"> 44 + <NavMenu 45 + href={`/at://${props.did}`} 46 + label={agent()?.sub === props.did ? "Go to repo (g)" : "Go to repo"} 47 + icon="lucide--user-round" 48 + /> 49 + <ActionMenu 50 + icon="lucide--settings" 51 + label="Edit permissions" 52 + onClick={() => props.onEditPermissions(props.did)} 53 + /> 54 + <ActionMenu 55 + icon="lucide--x" 56 + label="Remove account" 57 + onClick={() => removeSession(props.did)} 58 + /> 59 + </DropdownMenu> 60 + </MenuProvider> 61 + ); 62 + }; 63 + 64 + export const AccountManager = () => { 65 + const [openManager, setOpenManager] = createSignal(false); 66 + const [avatars, setAvatars] = createStore<Record<Did, string>>(); 67 + const [showingAddAccount, setShowingAddAccount] = createSignal(false); 68 + 69 + const getThumbnailUrl = (avatarUrl: string) => { 70 + return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); 71 + }; 72 + 73 + const scopeFlow = useOAuthScopeFlow({ 74 + beforeRedirect: (account) => resumeSession(account as Did), 75 + }); 76 + 77 + const handleAccountClick = async (did: Did) => { 78 + try { 79 + await resumeSession(did); 80 + } catch { 81 + scopeFlow.initiate(did); 82 + } 83 + }; 84 + 85 + onMount(async () => { 86 + try { 87 + await retrieveSession(); 88 + } catch {} 89 + 90 + const storedSessions = loadSessionsFromStorage(); 91 + if (storedSessions) { 92 + const sessionDids = Object.keys(storedSessions) as Did[]; 93 + sessionDids.forEach(async (did) => { 94 + await loadHandleForSession(did, storedSessions); 95 + }); 96 + sessionDids.forEach(async (did) => { 97 + const avatar = await getAvatar(did); 98 + if (avatar) setAvatars(did, avatar); 99 + }); 100 + } 101 + }); 102 + 103 + return ( 104 + <> 105 + <Modal 106 + open={openManager()} 107 + onClose={() => { 108 + setOpenManager(false); 109 + setShowingAddAccount(false); 110 + scopeFlow.cancel(); 111 + }} 112 + > 113 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-18 left-[50%] w-88 -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 114 + <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 115 + <div class="mb-2 px-1 font-semibold"> 116 + <span>Manage accounts</span> 117 + </div> 118 + <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 119 + <For each={Object.keys(sessions)}> 120 + {(did) => ( 121 + <div class="flex w-full items-center justify-between"> 122 + <A 123 + href={`/at://${did}`} 124 + onClick={() => setOpenManager(false)} 125 + class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 126 + > 127 + <Show 128 + when={avatars[did as Did]} 129 + fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 130 + > 131 + <img 132 + src={getThumbnailUrl(avatars[did as Did])} 133 + class="size-6 rounded-full" 134 + /> 135 + </Show> 136 + </A> 137 + <button 138 + class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 139 + onclick={() => handleAccountClick(did as Did)} 140 + > 141 + <span class="truncate">{sessions[did]?.handle || did}</span> 142 + <Show when={did === agent()?.sub && sessions[did].signedIn}> 143 + <span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span> 144 + </Show> 145 + <Show when={!sessions[did].signedIn}> 146 + <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 147 + </Show> 148 + </button> 149 + <AccountDropdown 150 + did={did as Did} 151 + onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 152 + /> 153 + </div> 154 + )} 155 + </For> 156 + </div> 157 + <button 158 + onclick={() => setShowingAddAccount(true)} 159 + class="flex w-full items-center justify-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 160 + > 161 + <span class="iconify lucide--user-plus"></span> 162 + <span>Add account</span> 163 + </button> 164 + </Show> 165 + 166 + <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 167 + <Login onCancel={() => setShowingAddAccount(false)} /> 168 + </Show> 169 + 170 + <Show when={scopeFlow.showScopeSelector()}> 171 + <ScopeSelector 172 + initialScopes={parseScopeString( 173 + sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 174 + )} 175 + onConfirm={scopeFlow.complete} 176 + onCancel={() => { 177 + scopeFlow.cancel(); 178 + setShowingAddAccount(false); 179 + }} 180 + /> 181 + </Show> 182 + </div> 183 + </Modal> 184 + <button 185 + onclick={() => setOpenManager(true)} 186 + class={`flex items-center rounded-lg ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 187 + > 188 + {agent() && avatars[agent()!.sub] ? 189 + <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" /> 190 + : <span class="iconify lucide--circle-user-round text-lg"></span>} 191 + </button> 192 + </> 193 + ); 194 + };
+88
src/auth/login.tsx
··· 1 + import { createSignal, Show } from "solid-js"; 2 + import "./oauth-config"; 3 + import { useOAuthScopeFlow } from "./scope-flow"; 4 + import { ScopeSelector } from "./scope-selector"; 5 + 6 + interface LoginProps { 7 + onCancel?: () => void; 8 + } 9 + 10 + export const Login = (props: LoginProps) => { 11 + const [notice, setNotice] = createSignal(""); 12 + const [loginInput, setLoginInput] = createSignal(""); 13 + 14 + const scopeFlow = useOAuthScopeFlow({ 15 + onError: (e) => setNotice(`${e}`), 16 + onRedirecting: () => { 17 + setNotice(`Contacting your data server...`); 18 + setTimeout(() => setNotice(`Redirecting...`), 0); 19 + }, 20 + }); 21 + 22 + const initiateLogin = (handle: string) => { 23 + setNotice(""); 24 + scopeFlow.initiate(handle); 25 + }; 26 + 27 + const handleCancel = () => { 28 + scopeFlow.cancel(); 29 + setLoginInput(""); 30 + setNotice(""); 31 + props.onCancel?.(); 32 + }; 33 + 34 + return ( 35 + <div class="flex flex-col gap-y-2 px-1"> 36 + <Show when={!scopeFlow.showScopeSelector()}> 37 + <Show when={props.onCancel}> 38 + <div class="mb-1 flex items-center gap-2"> 39 + <button 40 + onclick={handleCancel} 41 + class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 42 + > 43 + <span class="iconify lucide--arrow-left"></span> 44 + </button> 45 + <div class="font-semibold">Add account</div> 46 + </div> 47 + </Show> 48 + <form class="flex flex-col gap-2" onsubmit={(e) => e.preventDefault()}> 49 + <label for="username" class="hidden"> 50 + Add account 51 + </label> 52 + <div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex grow items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400"> 53 + <label 54 + for="username" 55 + class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400" 56 + ></label> 57 + <input 58 + type="text" 59 + spellcheck={false} 60 + placeholder="user.bsky.social" 61 + id="username" 62 + name="username" 63 + autocomplete="username" 64 + autofocus 65 + aria-label="Your AT Protocol handle" 66 + class="grow py-1 select-none placeholder:text-sm focus:outline-none" 67 + onInput={(e) => setLoginInput(e.currentTarget.value)} 68 + /> 69 + </div> 70 + <button 71 + onclick={() => initiateLogin(loginInput())} 72 + class="grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 73 + > 74 + Continue 75 + </button> 76 + </form> 77 + </Show> 78 + 79 + <Show when={scopeFlow.showScopeSelector()}> 80 + <ScopeSelector onConfirm={scopeFlow.complete} onCancel={handleCancel} /> 81 + </Show> 82 + 83 + <Show when={notice()}> 84 + <div class="text-sm">{notice()}</div> 85 + </Show> 86 + </div> 87 + ); 88 + };
+13
src/auth/oauth-config.ts
··· 1 + import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client"; 2 + import { didDocumentResolver, handleResolver } from "../utils/api"; 3 + 4 + configureOAuth({ 5 + metadata: { 6 + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 7 + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 8 + }, 9 + identityResolver: defaultIdentityResolver({ 10 + handleResolver: handleResolver, 11 + didDocumentResolver: didDocumentResolver, 12 + }), 13 + });
+77
src/auth/scope-flow.ts
··· 1 + import { isDid, isHandle } from "@atcute/lexicons/syntax"; 2 + import { createAuthorizationUrl } from "@atcute/oauth-browser-client"; 3 + import { createSignal } from "solid-js"; 4 + 5 + interface UseOAuthScopeFlowOptions { 6 + onError?: (error: unknown) => void; 7 + onRedirecting?: () => void; 8 + beforeRedirect?: (account: string) => Promise<void>; 9 + } 10 + 11 + export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => { 12 + const [showScopeSelector, setShowScopeSelector] = createSignal(false); 13 + const [pendingAccount, setPendingAccount] = createSignal(""); 14 + const [shouldForceRedirect, setShouldForceRedirect] = createSignal(false); 15 + 16 + const initiate = (account: string) => { 17 + if (!account) return; 18 + setPendingAccount(account); 19 + setShouldForceRedirect(false); 20 + setShowScopeSelector(true); 21 + }; 22 + 23 + const initiateWithRedirect = (account: string) => { 24 + if (!account) return; 25 + setPendingAccount(account); 26 + setShouldForceRedirect(true); 27 + setShowScopeSelector(true); 28 + }; 29 + 30 + const complete = async (scopeString: string, scopeIds: string) => { 31 + try { 32 + const account = pendingAccount(); 33 + 34 + if (options.beforeRedirect && !shouldForceRedirect()) { 35 + try { 36 + await options.beforeRedirect(account); 37 + setShowScopeSelector(false); 38 + return; 39 + } catch {} 40 + } 41 + 42 + localStorage.setItem("pendingScopes", scopeIds); 43 + 44 + options.onRedirecting?.(); 45 + 46 + const authUrl = await createAuthorizationUrl({ 47 + scope: scopeString, 48 + target: 49 + isHandle(account) || isDid(account) ? 50 + { type: "account", identifier: account } 51 + : { type: "pds", serviceUrl: account }, 52 + }); 53 + 54 + await new Promise((resolve) => setTimeout(resolve, 250)); 55 + location.assign(authUrl); 56 + } catch (e) { 57 + console.error(e); 58 + options.onError?.(e); 59 + setShowScopeSelector(false); 60 + } 61 + }; 62 + 63 + const cancel = () => { 64 + setShowScopeSelector(false); 65 + setPendingAccount(""); 66 + setShouldForceRedirect(false); 67 + }; 68 + 69 + return { 70 + showScopeSelector, 71 + pendingAccount, 72 + initiate, 73 + initiateWithRedirect, 74 + complete, 75 + cancel, 76 + }; 77 + };
+86
src/auth/scope-selector.tsx
··· 1 + import { createSignal, For } from "solid-js"; 2 + import { buildScopeString, GRANULAR_SCOPES, scopeIdsToString } from "./scope-utils"; 3 + 4 + interface ScopeSelectorProps { 5 + onConfirm: (scopeString: string, scopeIds: string) => void; 6 + onCancel: () => void; 7 + initialScopes?: Set<string>; 8 + } 9 + 10 + export const ScopeSelector = (props: ScopeSelectorProps) => { 11 + const [selectedScopes, setSelectedScopes] = createSignal<Set<string>>( 12 + props.initialScopes || new Set(["create", "update", "delete", "blob"]), 13 + ); 14 + 15 + const isBlobDisabled = () => { 16 + const scopes = selectedScopes(); 17 + return !scopes.has("create") && !scopes.has("update"); 18 + }; 19 + 20 + const toggleScope = (scopeId: string) => { 21 + setSelectedScopes((prev) => { 22 + const newSet = new Set(prev); 23 + if (newSet.has(scopeId)) { 24 + newSet.delete(scopeId); 25 + if ( 26 + (scopeId === "create" || scopeId === "update") && 27 + !newSet.has("create") && 28 + !newSet.has("update") 29 + ) { 30 + newSet.delete("blob"); 31 + } 32 + } else { 33 + newSet.add(scopeId); 34 + } 35 + return newSet; 36 + }); 37 + }; 38 + 39 + const handleConfirm = () => { 40 + const scopes = selectedScopes(); 41 + const scopeString = buildScopeString(scopes); 42 + const scopeIds = scopeIdsToString(scopes); 43 + props.onConfirm(scopeString, scopeIds); 44 + }; 45 + 46 + return ( 47 + <div class="flex flex-col gap-y-2"> 48 + <div class="mb-1 flex items-center gap-2"> 49 + <button 50 + onclick={props.onCancel} 51 + class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 52 + > 53 + <span class="iconify lucide--arrow-left"></span> 54 + </button> 55 + <div class="font-semibold">Select permissions</div> 56 + </div> 57 + <div class="flex flex-col gap-y-2 px-1"> 58 + <For each={GRANULAR_SCOPES}> 59 + {(scope) => ( 60 + <div 61 + class="flex items-center gap-2" 62 + classList={{ "opacity-50": scope.id === "blob" && isBlobDisabled() }} 63 + > 64 + <input 65 + id={`scope-${scope.id}`} 66 + type="checkbox" 67 + checked={selectedScopes().has(scope.id)} 68 + disabled={scope.id === "blob" && isBlobDisabled()} 69 + onChange={() => toggleScope(scope.id)} 70 + /> 71 + <label for={`scope-${scope.id}`} class="flex grow items-center gap-2 select-none"> 72 + <span>{scope.label}</span> 73 + </label> 74 + </div> 75 + )} 76 + </For> 77 + </div> 78 + <button 79 + onclick={handleConfirm} 80 + class="mt-2 grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 81 + > 82 + Continue 83 + </button> 84 + </div> 85 + ); 86 + };
+53
src/auth/scope-utils.ts
··· 1 + import { agent, sessions } from "./state"; 2 + 3 + export const GRANULAR_SCOPES = [ 4 + { 5 + id: "create", 6 + scope: "repo:*?action=create", 7 + label: "Create records", 8 + }, 9 + { 10 + id: "update", 11 + scope: "repo:*?action=update", 12 + label: "Update records", 13 + }, 14 + { 15 + id: "delete", 16 + scope: "repo:*?action=delete", 17 + label: "Delete records", 18 + }, 19 + { 20 + id: "blob", 21 + scope: "blob:*/*", 22 + label: "Upload blobs", 23 + }, 24 + ]; 25 + 26 + export const BASE_SCOPES = ["atproto"]; 27 + 28 + export const buildScopeString = (selected: Set<string>): string => { 29 + const granular = GRANULAR_SCOPES.filter((s) => selected.has(s.id)).map((s) => s.scope); 30 + return [...BASE_SCOPES, ...granular].join(" "); 31 + }; 32 + 33 + export const scopeIdsToString = (scopeIds: Set<string>): string => { 34 + return ["atproto", ...Array.from(scopeIds)].join(","); 35 + }; 36 + 37 + export const parseScopeString = (scopeIdsString: string): Set<string> => { 38 + if (!scopeIdsString) return new Set(); 39 + const ids = scopeIdsString.split(",").filter(Boolean); 40 + return new Set(ids.filter((id) => id !== "atproto")); 41 + }; 42 + 43 + export const hasScope = (grantedScopes: string | undefined, scopeId: string): boolean => { 44 + if (!grantedScopes) return false; 45 + return grantedScopes.split(",").includes(scopeId); 46 + }; 47 + 48 + export const hasUserScope = (scopeId: string): boolean => { 49 + if (!agent()) return false; 50 + const grantedScopes = sessions[agent()!.sub]?.grantedScopes; 51 + if (!grantedScopes) return true; 52 + return hasScope(grantedScopes, scopeId); 53 + };
+95
src/auth/session-manager.ts
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { Did } from "@atcute/lexicons"; 3 + import { 4 + finalizeAuthorization, 5 + getSession, 6 + OAuthUserAgent, 7 + type Session, 8 + } from "@atcute/oauth-browser-client"; 9 + import { resolveDidDoc } from "../utils/api"; 10 + import { Sessions, setAgent, setSessions } from "./state"; 11 + 12 + export const saveSessionToStorage = (sessions: Sessions) => { 13 + localStorage.setItem("sessions", JSON.stringify(sessions)); 14 + }; 15 + 16 + export const loadSessionsFromStorage = (): Sessions | null => { 17 + const localSessions = localStorage.getItem("sessions"); 18 + return localSessions ? JSON.parse(localSessions) : null; 19 + }; 20 + 21 + export const getAvatar = async (did: Did): Promise<string | undefined> => { 22 + const rpc = new Client({ 23 + handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 24 + }); 25 + const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } }); 26 + if (res.ok) { 27 + return res.data.avatar; 28 + } 29 + return undefined; 30 + }; 31 + 32 + export const loadHandleForSession = async (did: Did, storedSessions: Sessions) => { 33 + const doc = await resolveDidDoc(did); 34 + const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://")); 35 + if (alias) { 36 + setSessions(did, { 37 + signedIn: storedSessions[did].signedIn, 38 + handle: alias.replace("at://", ""), 39 + grantedScopes: storedSessions[did].grantedScopes, 40 + }); 41 + } 42 + }; 43 + 44 + export const retrieveSession = async (): Promise<void> => { 45 + const init = async (): Promise<Session | undefined> => { 46 + const params = new URLSearchParams(location.hash.slice(1)); 47 + 48 + if (params.has("state") && (params.has("code") || params.has("error"))) { 49 + history.replaceState(null, "", location.pathname + location.search); 50 + 51 + const auth = await finalizeAuthorization(params); 52 + const did = auth.session.info.sub; 53 + 54 + localStorage.setItem("lastSignedIn", did); 55 + 56 + const grantedScopes = localStorage.getItem("pendingScopes") || "atproto"; 57 + localStorage.removeItem("pendingScopes"); 58 + 59 + const sessions = loadSessionsFromStorage(); 60 + const newSessions: Sessions = sessions || {}; 61 + newSessions[did] = { signedIn: true, grantedScopes }; 62 + saveSessionToStorage(newSessions); 63 + return auth.session; 64 + } else { 65 + const lastSignedIn = localStorage.getItem("lastSignedIn"); 66 + 67 + if (lastSignedIn) { 68 + const sessions = loadSessionsFromStorage(); 69 + const newSessions: Sessions = sessions || {}; 70 + try { 71 + const session = await getSession(lastSignedIn as Did); 72 + const rpc = new Client({ handler: new OAuthUserAgent(session) }); 73 + const res = await rpc.get("com.atproto.server.getSession"); 74 + newSessions[lastSignedIn].signedIn = true; 75 + saveSessionToStorage(newSessions); 76 + if (!res.ok) throw res.data.error; 77 + return session; 78 + } catch (err) { 79 + newSessions[lastSignedIn].signedIn = false; 80 + saveSessionToStorage(newSessions); 81 + throw err; 82 + } 83 + } 84 + } 85 + }; 86 + 87 + const session = await init(); 88 + 89 + if (session) setAgent(new OAuthUserAgent(session)); 90 + }; 91 + 92 + export const resumeSession = async (did: Did): Promise<void> => { 93 + localStorage.setItem("lastSignedIn", did); 94 + await retrieveSession(); 95 + };
+14
src/auth/state.ts
··· 1 + import { OAuthUserAgent } from "@atcute/oauth-browser-client"; 2 + import { createSignal } from "solid-js"; 3 + import { createStore } from "solid-js/store"; 4 + 5 + export type Account = { 6 + signedIn: boolean; 7 + handle?: string; 8 + grantedScopes?: string; 9 + }; 10 + 11 + export type Sessions = Record<string, Account>; 12 + 13 + export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 + export const [sessions, setSessions] = createStore<Sessions>();
-159
src/components/account.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 2 - import { Did } from "@atcute/lexicons"; 3 - import { 4 - createAuthorizationUrl, 5 - deleteStoredSession, 6 - getSession, 7 - OAuthUserAgent, 8 - } from "@atcute/oauth-browser-client"; 9 - import { A } from "@solidjs/router"; 10 - import { createSignal, For, onMount, Show } from "solid-js"; 11 - import { createStore, produce } from "solid-js/store"; 12 - import { resolveDidDoc } from "../utils/api.js"; 13 - import { agent, Login, retrieveSession, Sessions, setAgent } from "./login.jsx"; 14 - import { Modal } from "./modal.jsx"; 15 - 16 - export const [sessions, setSessions] = createStore<Sessions>(); 17 - 18 - export const AccountManager = () => { 19 - const [openManager, setOpenManager] = createSignal(false); 20 - const [avatars, setAvatars] = createStore<Record<Did, string>>(); 21 - 22 - onMount(async () => { 23 - try { 24 - await retrieveSession(); 25 - } catch {} 26 - 27 - const localSessions = localStorage.getItem("sessions"); 28 - if (localSessions) { 29 - const storedSessions: Sessions = JSON.parse(localSessions); 30 - const sessionDids = Object.keys(storedSessions) as Did[]; 31 - sessionDids.forEach(async (did) => { 32 - const doc = await resolveDidDoc(did); 33 - const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://")); 34 - if (alias) { 35 - setSessions(did, { 36 - signedIn: storedSessions[did].signedIn, 37 - handle: alias.replace("at://", ""), 38 - }); 39 - } 40 - }); 41 - sessionDids.forEach(async (did) => { 42 - const avatar = await getAvatar(did); 43 - if (avatar) setAvatars(did, avatar); 44 - }); 45 - } 46 - }); 47 - 48 - const resumeSession = async (did: Did) => { 49 - try { 50 - localStorage.setItem("lastSignedIn", did); 51 - await retrieveSession(); 52 - } catch { 53 - const authUrl = await createAuthorizationUrl({ 54 - scope: import.meta.env.VITE_OAUTH_SCOPE, 55 - target: { type: "account", identifier: did }, 56 - }); 57 - 58 - await new Promise((resolve) => setTimeout(resolve, 250)); 59 - 60 - location.assign(authUrl); 61 - } 62 - }; 63 - 64 - const removeSession = async (did: Did) => { 65 - const currentSession = agent()?.sub; 66 - try { 67 - const session = await getSession(did, { allowStale: true }); 68 - const agent = new OAuthUserAgent(session); 69 - await agent.signOut(); 70 - } catch { 71 - deleteStoredSession(did); 72 - } 73 - setSessions( 74 - produce((accs) => { 75 - delete accs[did]; 76 - }), 77 - ); 78 - localStorage.setItem("sessions", JSON.stringify(sessions)); 79 - if (currentSession === did) setAgent(undefined); 80 - }; 81 - 82 - const getAvatar = async (did: Did) => { 83 - const rpc = new Client({ 84 - handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 85 - }); 86 - const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } }); 87 - if (res.ok) { 88 - return res.data.avatar; 89 - } 90 - return undefined; 91 - }; 92 - 93 - return ( 94 - <> 95 - <Modal open={openManager()} onClose={() => setOpenManager(false)}> 96 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-18 left-[50%] w-88 -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 97 - <div class="mb-2 px-1 font-semibold"> 98 - <span>Manage accounts</span> 99 - </div> 100 - <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 101 - <For each={Object.keys(sessions)}> 102 - {(did) => ( 103 - <div class="flex w-full items-center justify-between"> 104 - <A 105 - href={`/at://${did}`} 106 - onClick={() => setOpenManager(false)} 107 - class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 108 - > 109 - <Show 110 - when={avatars[did as Did]} 111 - fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 112 - > 113 - <img 114 - src={avatars[did as Did].replace("img/avatar/", "img/avatar_thumbnail/")} 115 - class="size-6 rounded-full" 116 - /> 117 - </Show> 118 - </A> 119 - <button 120 - class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 121 - onclick={() => resumeSession(did as Did)} 122 - > 123 - <span class="truncate"> 124 - {sessions[did]?.handle ? sessions[did].handle : did} 125 - </span> 126 - <Show when={did === agent()?.sub && sessions[did].signedIn}> 127 - <span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span> 128 - </Show> 129 - <Show when={!sessions[did].signedIn}> 130 - <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 131 - </Show> 132 - </button> 133 - <button 134 - onclick={() => removeSession(did as Did)} 135 - class="flex items-center rounded-md p-2 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 136 - > 137 - <span class="iconify lucide--x"></span> 138 - </button> 139 - </div> 140 - )} 141 - </For> 142 - </div> 143 - <Login /> 144 - </div> 145 - </Modal> 146 - <button 147 - onclick={() => setOpenManager(true)} 148 - class={`flex items-center rounded-lg ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 149 - > 150 - {agent() && avatars[agent()!.sub] ? 151 - <img 152 - src={avatars[agent()!.sub].replace("img/avatar/", "img/avatar_thumbnail/")} 153 - class="size-5 rounded-full" 154 - /> 155 - : <span class="iconify lucide--circle-user-round text-lg"></span>} 156 - </button> 157 - </> 158 - ); 159 - };
+109
src/components/create/file-upload.tsx
··· 1 + import { Client } from "@atcute/client"; 2 + import { remove } from "@mary/exif-rm"; 3 + import { createSignal, onCleanup, Show } from "solid-js"; 4 + import { agent } from "../../auth/state"; 5 + import { Button } from "../button.jsx"; 6 + import { TextInput } from "../text-input.jsx"; 7 + import { editorInstance } from "./state"; 8 + 9 + export const FileUpload = (props: { 10 + file: File; 11 + blobInput: HTMLInputElement; 12 + onClose: () => void; 13 + }) => { 14 + const [uploading, setUploading] = createSignal(false); 15 + const [error, setError] = createSignal(""); 16 + 17 + onCleanup(() => (props.blobInput.value = "")); 18 + 19 + const formatFileSize = (bytes: number) => { 20 + if (bytes === 0) return "0 Bytes"; 21 + const k = 1024; 22 + const sizes = ["Bytes", "KB", "MB", "GB"]; 23 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 24 + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 25 + }; 26 + 27 + const uploadBlob = async () => { 28 + let blob: Blob; 29 + 30 + const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 31 + (document.getElementById("mimetype") as HTMLInputElement).value = ""; 32 + if (mimetype) blob = new Blob([props.file], { type: mimetype }); 33 + else blob = props.file; 34 + 35 + if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 36 + const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 37 + if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); 38 + } 39 + 40 + const rpc = new Client({ handler: agent()! }); 41 + setUploading(true); 42 + const res = await rpc.post("com.atproto.repo.uploadBlob", { 43 + input: blob, 44 + }); 45 + setUploading(false); 46 + if (!res.ok) { 47 + setError(res.data.error); 48 + return; 49 + } 50 + editorInstance.view.dispatch({ 51 + changes: { 52 + from: editorInstance.view.state.selection.main.head, 53 + insert: JSON.stringify(res.data.blob, null, 2), 54 + }, 55 + }); 56 + props.onClose(); 57 + }; 58 + 59 + return ( 60 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-[20rem] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 61 + <h2 class="mb-2 font-semibold">Upload blob</h2> 62 + <div class="flex flex-col gap-2 text-sm"> 63 + <div class="flex flex-col gap-1"> 64 + <p class="flex gap-1"> 65 + <span class="truncate">{props.file.name}</span> 66 + <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 67 + ({formatFileSize(props.file.size)}) 68 + </span> 69 + </p> 70 + </div> 71 + <div class="flex items-center gap-x-2"> 72 + <label for="mimetype" class="shrink-0 select-none"> 73 + MIME type 74 + </label> 75 + <TextInput id="mimetype" placeholder={props.file.type} /> 76 + </div> 77 + <div class="flex items-center gap-1"> 78 + <input id="exif-rm" type="checkbox" checked /> 79 + <label for="exif-rm" class="select-none"> 80 + Remove EXIF data 81 + </label> 82 + </div> 83 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 84 + Metadata will be pasted after the cursor 85 + </p> 86 + <Show when={error()}> 87 + <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 88 + </Show> 89 + <div class="flex justify-between gap-2"> 90 + <Button onClick={props.onClose}>Cancel</Button> 91 + <Show when={uploading()}> 92 + <div class="flex items-center gap-1"> 93 + <span class="iconify lucide--loader-circle animate-spin"></span> 94 + <span>Uploading</span> 95 + </div> 96 + </Show> 97 + <Show when={!uploading()}> 98 + <Button 99 + onClick={uploadBlob} 100 + class="dark:shadow-dark-700 flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400" 101 + > 102 + Upload 103 + </Button> 104 + </Show> 105 + </div> 106 + </div> 107 + </div> 108 + ); 109 + };
+87
src/components/create/handle-input.tsx
··· 1 + import { Handle } from "@atcute/lexicons"; 2 + import { createSignal, Show } from "solid-js"; 3 + import { resolveHandle } from "../../utils/api"; 4 + import { Button } from "../button.jsx"; 5 + import { TextInput } from "../text-input.jsx"; 6 + import { editorInstance } from "./state"; 7 + 8 + export const HandleInput = (props: { onClose: () => void }) => { 9 + const [resolving, setResolving] = createSignal(false); 10 + const [error, setError] = createSignal(""); 11 + let handleFormRef!: HTMLFormElement; 12 + 13 + const resolveDid = async (e: SubmitEvent) => { 14 + e.preventDefault(); 15 + const formData = new FormData(handleFormRef); 16 + const handleValue = formData.get("handle")?.toString().trim(); 17 + 18 + if (!handleValue) { 19 + setError("Please enter a handle"); 20 + return; 21 + } 22 + 23 + setResolving(true); 24 + setError(""); 25 + try { 26 + const did = await resolveHandle(handleValue as Handle); 27 + editorInstance.view.dispatch({ 28 + changes: { 29 + from: editorInstance.view.state.selection.main.head, 30 + insert: `"${did}"`, 31 + }, 32 + }); 33 + props.onClose(); 34 + handleFormRef.reset(); 35 + } catch (err: any) { 36 + setError(err.message || "Failed to resolve handle"); 37 + } finally { 38 + setResolving(false); 39 + } 40 + }; 41 + 42 + return ( 43 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-[20rem] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 44 + <h2 class="mb-2 font-semibold">Insert DID from handle</h2> 45 + <form ref={handleFormRef} onSubmit={resolveDid} class="flex flex-col gap-2 text-sm"> 46 + <div class="flex flex-col gap-1"> 47 + <label for="handle-input" class="select-none"> 48 + Handle 49 + </label> 50 + <TextInput id="handle-input" name="handle" placeholder="user.bsky.social" /> 51 + </div> 52 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 53 + DID will be pasted after the cursor 54 + </p> 55 + <Show when={error()}> 56 + <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 57 + </Show> 58 + <div class="flex justify-between gap-2"> 59 + <Button 60 + type="button" 61 + onClick={() => { 62 + props.onClose(); 63 + handleFormRef.reset(); 64 + setError(""); 65 + }} 66 + > 67 + Cancel 68 + </Button> 69 + <Show when={resolving()}> 70 + <div class="flex items-center gap-1"> 71 + <span class="iconify lucide--loader-circle animate-spin"></span> 72 + <span>Resolving</span> 73 + </div> 74 + </Show> 75 + <Show when={!resolving()}> 76 + <Button 77 + type="submit" 78 + class="dark:shadow-dark-700 flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400" 79 + > 80 + Insert 81 + </Button> 82 + </Show> 83 + </div> 84 + </form> 85 + </div> 86 + ); 87 + };
+479
src/components/create/index.tsx
··· 1 + import { Client } from "@atcute/client"; 2 + import { Did } from "@atcute/lexicons"; 3 + import { isNsid, isRecordKey } from "@atcute/lexicons/syntax"; 4 + import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 + import { useNavigate, useParams } from "@solidjs/router"; 6 + import { 7 + createEffect, 8 + createSignal, 9 + For, 10 + lazy, 11 + onCleanup, 12 + onMount, 13 + Show, 14 + Suspense, 15 + } from "solid-js"; 16 + import { hasUserScope } from "../../auth/scope-utils"; 17 + import { agent, sessions } from "../../auth/state"; 18 + import { Button } from "../button.jsx"; 19 + import { Modal } from "../modal.jsx"; 20 + import { addNotification, removeNotification } from "../notification.jsx"; 21 + import { TextInput } from "../text-input.jsx"; 22 + import Tooltip from "../tooltip.jsx"; 23 + import { FileUpload } from "./file-upload"; 24 + import { HandleInput } from "./handle-input"; 25 + import { MenuItem } from "./menu-item"; 26 + import { editorInstance, placeholder, setPlaceholder } from "./state"; 27 + 28 + const Editor = lazy(() => import("../editor.jsx").then((m) => ({ default: m.Editor }))); 29 + 30 + export { editorInstance, placeholder, setPlaceholder }; 31 + 32 + export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 33 + const navigate = useNavigate(); 34 + const params = useParams(); 35 + const [openDialog, setOpenDialog] = createSignal(false); 36 + const [notice, setNotice] = createSignal(""); 37 + const [openUpload, setOpenUpload] = createSignal(false); 38 + const [openInsertMenu, setOpenInsertMenu] = createSignal(false); 39 + const [openHandleDialog, setOpenHandleDialog] = createSignal(false); 40 + const [validate, setValidate] = createSignal<boolean | undefined>(undefined); 41 + const [isMaximized, setIsMaximized] = createSignal(false); 42 + const [isMinimized, setIsMinimized] = createSignal(false); 43 + const [collectionError, setCollectionError] = createSignal(""); 44 + const [rkeyError, setRkeyError] = createSignal(""); 45 + let blobInput!: HTMLInputElement; 46 + let formRef!: HTMLFormElement; 47 + let insertMenuRef!: HTMLDivElement; 48 + 49 + createEffect(() => { 50 + if (openInsertMenu()) { 51 + const handleClickOutside = (e: MouseEvent) => { 52 + if (insertMenuRef && !insertMenuRef.contains(e.target as Node)) { 53 + setOpenInsertMenu(false); 54 + } 55 + }; 56 + document.addEventListener("mousedown", handleClickOutside); 57 + onCleanup(() => document.removeEventListener("mousedown", handleClickOutside)); 58 + } 59 + }); 60 + 61 + onMount(() => { 62 + const keyEvent = (ev: KeyboardEvent) => { 63 + if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; 64 + if ((ev.target as HTMLElement).closest("[data-modal]")) return; 65 + 66 + const key = props.create ? "n" : "e"; 67 + if (ev.key === key) { 68 + ev.preventDefault(); 69 + 70 + if (openDialog() && isMinimized()) { 71 + setIsMinimized(false); 72 + } else if (!openDialog() && !document.querySelector("[data-modal]")) { 73 + setOpenDialog(true); 74 + } 75 + } 76 + }; 77 + 78 + window.addEventListener("keydown", keyEvent); 79 + onCleanup(() => window.removeEventListener("keydown", keyEvent)); 80 + }); 81 + 82 + const defaultPlaceholder = () => { 83 + return { 84 + $type: "app.bsky.feed.post", 85 + text: "This post was sent from PDSls", 86 + embed: { 87 + $type: "app.bsky.embed.external", 88 + external: { 89 + uri: "https://pdsls.dev", 90 + title: "PDSls", 91 + description: "Browse the public data on atproto", 92 + }, 93 + }, 94 + langs: ["en"], 95 + createdAt: new Date().toISOString(), 96 + }; 97 + }; 98 + 99 + const getValidateIcon = () => { 100 + return ( 101 + validate() === true ? "lucide--circle-check" 102 + : validate() === false ? "lucide--circle-x" 103 + : "lucide--circle" 104 + ); 105 + }; 106 + 107 + const getValidateLabel = () => { 108 + return ( 109 + validate() === true ? "True" 110 + : validate() === false ? "False" 111 + : "Unset" 112 + ); 113 + }; 114 + 115 + createEffect(() => { 116 + if (openDialog()) { 117 + setValidate(undefined); 118 + setCollectionError(""); 119 + setRkeyError(""); 120 + } 121 + }); 122 + 123 + const createRecord = async (formData: FormData) => { 124 + const repo = formData.get("repo")?.toString(); 125 + if (!repo) return; 126 + const rpc = new Client({ handler: new OAuthUserAgent(await getSession(repo as Did)) }); 127 + const collection = formData.get("collection"); 128 + const rkey = formData.get("rkey"); 129 + let record: any; 130 + try { 131 + record = JSON.parse(editorInstance.view.state.doc.toString()); 132 + } catch (e: any) { 133 + setNotice(e.message); 134 + return; 135 + } 136 + const res = await rpc.post("com.atproto.repo.createRecord", { 137 + input: { 138 + repo: repo as Did, 139 + collection: collection ? collection.toString() : record.$type, 140 + rkey: rkey?.toString().length ? rkey?.toString() : undefined, 141 + record: record, 142 + validate: validate(), 143 + }, 144 + }); 145 + if (!res.ok) { 146 + setNotice(`${res.data.error}: ${res.data.message}`); 147 + return; 148 + } 149 + setOpenDialog(false); 150 + const id = addNotification({ 151 + message: "Record created", 152 + type: "success", 153 + }); 154 + setTimeout(() => removeNotification(id), 3000); 155 + navigate(`/${res.data.uri}`); 156 + }; 157 + 158 + const editRecord = async (recreate?: boolean) => { 159 + const record = editorInstance.view.state.doc.toString(); 160 + if (!record) return; 161 + const rpc = new Client({ handler: agent()! }); 162 + try { 163 + const editedRecord = JSON.parse(record); 164 + if (recreate) { 165 + const res = await rpc.post("com.atproto.repo.applyWrites", { 166 + input: { 167 + repo: agent()!.sub, 168 + validate: validate(), 169 + writes: [ 170 + { 171 + collection: params.collection as `${string}.${string}.${string}`, 172 + rkey: params.rkey!, 173 + $type: "com.atproto.repo.applyWrites#delete", 174 + }, 175 + { 176 + collection: params.collection as `${string}.${string}.${string}`, 177 + rkey: params.rkey, 178 + $type: "com.atproto.repo.applyWrites#create", 179 + value: editedRecord, 180 + }, 181 + ], 182 + }, 183 + }); 184 + if (!res.ok) { 185 + setNotice(`${res.data.error}: ${res.data.message}`); 186 + return; 187 + } 188 + } else { 189 + const res = await rpc.post("com.atproto.repo.applyWrites", { 190 + input: { 191 + repo: agent()!.sub, 192 + validate: validate(), 193 + writes: [ 194 + { 195 + collection: params.collection as `${string}.${string}.${string}`, 196 + rkey: params.rkey!, 197 + $type: "com.atproto.repo.applyWrites#update", 198 + value: editedRecord, 199 + }, 200 + ], 201 + }, 202 + }); 203 + if (!res.ok) { 204 + setNotice(`${res.data.error}: ${res.data.message}`); 205 + return; 206 + } 207 + } 208 + setOpenDialog(false); 209 + const id = addNotification({ 210 + message: "Record edited", 211 + type: "success", 212 + }); 213 + setTimeout(() => removeNotification(id), 3000); 214 + props.refetch(); 215 + } catch (err: any) { 216 + setNotice(err.message); 217 + } 218 + }; 219 + 220 + const insertTimestamp = () => { 221 + const timestamp = new Date().toISOString(); 222 + editorInstance.view.dispatch({ 223 + changes: { 224 + from: editorInstance.view.state.selection.main.head, 225 + insert: `"${timestamp}"`, 226 + }, 227 + }); 228 + setOpenInsertMenu(false); 229 + }; 230 + 231 + const insertDidFromHandle = () => { 232 + setOpenInsertMenu(false); 233 + setOpenHandleDialog(true); 234 + }; 235 + 236 + return ( 237 + <> 238 + <Modal 239 + open={openDialog()} 240 + onClose={() => setOpenDialog(false)} 241 + closeOnClick={false} 242 + nonBlocking={isMinimized()} 243 + > 244 + <div 245 + style="transform: translateX(-50%) translateZ(0);" 246 + classList={{ 247 + "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto absolute top-18 left-1/2 flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-all duration-200 dark:border-neutral-700 starting:opacity-0": true, 248 + "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(), 249 + "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(), 250 + hidden: isMinimized(), 251 + }} 252 + > 253 + <div class="mb-2 flex w-full justify-between text-base"> 254 + <div class="flex items-center gap-2"> 255 + <span class="font-semibold select-none"> 256 + {props.create ? "Creating" : "Editing"} record 257 + </span> 258 + </div> 259 + <div class="flex items-center gap-1"> 260 + <button 261 + type="button" 262 + onclick={() => setIsMinimized(true)} 263 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 264 + > 265 + <span class="iconify lucide--minus"></span> 266 + </button> 267 + <button 268 + type="button" 269 + onclick={() => setIsMaximized(!isMaximized())} 270 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 271 + > 272 + <span 273 + class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 274 + ></span> 275 + </button> 276 + <button 277 + id="close" 278 + onclick={() => setOpenDialog(false)} 279 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 280 + > 281 + <span class="iconify lucide--x"></span> 282 + </button> 283 + </div> 284 + </div> 285 + <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 286 + <Show when={props.create}> 287 + <div class="flex flex-wrap items-center gap-1 text-sm"> 288 + <span>at://</span> 289 + <select 290 + class="dark:bg-dark-100 max-w-40 truncate rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 291 + name="repo" 292 + id="repo" 293 + > 294 + <For each={Object.keys(sessions)}> 295 + {(session) => ( 296 + <option value={session} selected={session === agent()?.sub}> 297 + {sessions[session].handle ?? session} 298 + </option> 299 + )} 300 + </For> 301 + </select> 302 + <span>/</span> 303 + <TextInput 304 + id="collection" 305 + name="collection" 306 + placeholder="Collection (default: $type)" 307 + class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 308 + onInput={(e) => { 309 + const value = e.currentTarget.value; 310 + if (!value || isNsid(value)) setCollectionError(""); 311 + else 312 + setCollectionError( 313 + "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", 314 + ); 315 + }} 316 + /> 317 + <span>/</span> 318 + <TextInput 319 + id="rkey" 320 + name="rkey" 321 + placeholder="Record key (default: TID)" 322 + class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 323 + onInput={(e) => { 324 + const value = e.currentTarget.value; 325 + if (!value || isRecordKey(value)) setRkeyError(""); 326 + else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); 327 + }} 328 + /> 329 + </div> 330 + <Show when={collectionError() || rkeyError()}> 331 + <div class="text-xs text-red-500 dark:text-red-400"> 332 + <div>{collectionError()}</div> 333 + <div>{rkeyError()}</div> 334 + </div> 335 + </Show> 336 + </Show> 337 + <div class="min-h-0 flex-1"> 338 + <Suspense 339 + fallback={ 340 + <div class="flex h-full items-center justify-center"> 341 + <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 342 + </div> 343 + } 344 + > 345 + <Editor 346 + content={JSON.stringify( 347 + !props.create ? props.record 348 + : params.rkey ? placeholder() 349 + : defaultPlaceholder(), 350 + null, 351 + 2, 352 + )} 353 + /> 354 + </Suspense> 355 + </div> 356 + <div class="flex flex-col gap-2"> 357 + <Show when={notice()}> 358 + <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 359 + </Show> 360 + <div class="flex justify-between gap-2"> 361 + <div class="relative" ref={insertMenuRef}> 362 + <button 363 + type="button" 364 + class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 flex w-fit rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 text-base shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 365 + onClick={() => setOpenInsertMenu(!openInsertMenu())} 366 + > 367 + <span class="iconify lucide--plus select-none"></span> 368 + </button> 369 + <Show when={openInsertMenu()}> 370 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 371 + <MenuItem 372 + icon="lucide--id-card" 373 + label="Insert DID" 374 + onClick={insertDidFromHandle} 375 + /> 376 + <MenuItem 377 + icon="lucide--clock" 378 + label="Insert timestamp" 379 + onClick={insertTimestamp} 380 + /> 381 + <Show when={hasUserScope("blob")}> 382 + <MenuItem 383 + icon="lucide--upload" 384 + label="Upload blob" 385 + onClick={() => { 386 + setOpenInsertMenu(false); 387 + blobInput.click(); 388 + }} 389 + /> 390 + </Show> 391 + </div> 392 + </Show> 393 + <input 394 + type="file" 395 + id="blob" 396 + class="sr-only" 397 + ref={blobInput} 398 + onChange={(e) => { 399 + if (e.target.files !== null) setOpenUpload(true); 400 + }} 401 + /> 402 + </div> 403 + <Modal 404 + open={openUpload()} 405 + onClose={() => setOpenUpload(false)} 406 + closeOnClick={false} 407 + > 408 + <FileUpload 409 + file={blobInput.files![0]} 410 + blobInput={blobInput} 411 + onClose={() => setOpenUpload(false)} 412 + /> 413 + </Modal> 414 + <Modal 415 + open={openHandleDialog()} 416 + onClose={() => setOpenHandleDialog(false)} 417 + closeOnClick={false} 418 + > 419 + <HandleInput onClose={() => setOpenHandleDialog(false)} /> 420 + </Modal> 421 + <div class="flex items-center justify-end gap-2"> 422 + <button 423 + type="button" 424 + class="flex items-center gap-1 rounded-sm p-1.5 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 425 + onClick={() => 426 + setValidate( 427 + validate() === true ? false 428 + : validate() === false ? undefined 429 + : true, 430 + ) 431 + } 432 + > 433 + <Tooltip text={getValidateLabel()}> 434 + <span class={`iconify ${getValidateIcon()}`}></span> 435 + </Tooltip> 436 + <span>Validate</span> 437 + </button> 438 + <Show when={!props.create && hasUserScope("create") && hasUserScope("delete")}> 439 + <Button onClick={() => editRecord(true)}>Recreate</Button> 440 + </Show> 441 + <Button 442 + onClick={() => 443 + props.create ? createRecord(new FormData(formRef)) : editRecord() 444 + } 445 + > 446 + {props.create ? "Create" : "Edit"} 447 + </Button> 448 + </div> 449 + </div> 450 + </div> 451 + </form> 452 + </div> 453 + </Modal> 454 + <Show when={isMinimized() && openDialog()}> 455 + <button 456 + class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 fixed right-4 bottom-4 z-30 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-2 shadow-md hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 457 + onclick={() => setIsMinimized(false)} 458 + > 459 + <span class="iconify lucide--square-pen text-lg"></span> 460 + <span class="text-sm font-medium">{props.create ? "Creating" : "Editing"} record</span> 461 + </button> 462 + </Show> 463 + <Tooltip text={props.create ? "Create record (n)" : "Edit record (e)"}> 464 + <button 465 + class={`flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`} 466 + onclick={() => { 467 + setNotice(""); 468 + setOpenDialog(true); 469 + setIsMinimized(false); 470 + }} 471 + > 472 + <div 473 + class={props.create ? "iconify lucide--square-pen text-lg" : "iconify lucide--pencil"} 474 + /> 475 + </button> 476 + </Tooltip> 477 + </> 478 + ); 479 + };
+12
src/components/create/menu-item.tsx
··· 1 + export const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => { 2 + return ( 3 + <button 4 + type="button" 5 + class="flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 6 + onClick={props.onClick} 7 + > 8 + <span class={`iconify ${props.icon}`}></span> 9 + <span>{props.label}</span> 10 + </button> 11 + ); 12 + };
+4
src/components/create/state.ts
··· 1 + import { createSignal } from "solid-js"; 2 + 3 + export const editorInstance = { view: null as any }; 4 + export const [placeholder, setPlaceholder] = createSignal<any>();
-529
src/components/create.tsx
··· 1 - import { Client } from "@atcute/client"; 2 - import { Did } from "@atcute/lexicons"; 3 - import { isNsid, isRecordKey } from "@atcute/lexicons/syntax"; 4 - import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 - import { remove } from "@mary/exif-rm"; 6 - import { useNavigate, useParams } from "@solidjs/router"; 7 - import { createEffect, createSignal, For, lazy, onCleanup, Show, Suspense } from "solid-js"; 8 - import { agent } from "../components/login.jsx"; 9 - import { sessions } from "./account.jsx"; 10 - import { Button } from "./button.jsx"; 11 - import { Modal } from "./modal.jsx"; 12 - import { addNotification, removeNotification } from "./notification.jsx"; 13 - import { TextInput } from "./text-input.jsx"; 14 - import Tooltip from "./tooltip.jsx"; 15 - 16 - const Editor = lazy(() => import("../components/editor.jsx").then((m) => ({ default: m.Editor }))); 17 - 18 - export const editorInstance = { view: null as any }; 19 - export const [placeholder, setPlaceholder] = createSignal<any>(); 20 - 21 - export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 22 - const navigate = useNavigate(); 23 - const params = useParams(); 24 - const [openDialog, setOpenDialog] = createSignal(false); 25 - const [notice, setNotice] = createSignal(""); 26 - const [openUpload, setOpenUpload] = createSignal(false); 27 - const [openInsertMenu, setOpenInsertMenu] = createSignal(false); 28 - const [validate, setValidate] = createSignal<boolean | undefined>(undefined); 29 - const [isMaximized, setIsMaximized] = createSignal(false); 30 - const [isMinimized, setIsMinimized] = createSignal(false); 31 - const [collectionError, setCollectionError] = createSignal(""); 32 - const [rkeyError, setRkeyError] = createSignal(""); 33 - let blobInput!: HTMLInputElement; 34 - let formRef!: HTMLFormElement; 35 - let insertMenuRef!: HTMLDivElement; 36 - 37 - createEffect(() => { 38 - if (openInsertMenu()) { 39 - const handleClickOutside = (e: MouseEvent) => { 40 - if (insertMenuRef && !insertMenuRef.contains(e.target as Node)) { 41 - setOpenInsertMenu(false); 42 - } 43 - }; 44 - document.addEventListener("mousedown", handleClickOutside); 45 - onCleanup(() => document.removeEventListener("mousedown", handleClickOutside)); 46 - } 47 - }); 48 - 49 - const defaultPlaceholder = () => { 50 - return { 51 - $type: "app.bsky.feed.post", 52 - text: "This post was sent from PDSls", 53 - embed: { 54 - $type: "app.bsky.embed.external", 55 - external: { 56 - uri: "https://pdsls.dev", 57 - title: "PDSls", 58 - description: "Browse the public data on atproto", 59 - }, 60 - }, 61 - langs: ["en"], 62 - createdAt: new Date().toISOString(), 63 - }; 64 - }; 65 - 66 - const getValidateIcon = () => { 67 - return ( 68 - validate() === true ? "lucide--circle-check" 69 - : validate() === false ? "lucide--circle-x" 70 - : "lucide--circle" 71 - ); 72 - }; 73 - 74 - const getValidateLabel = () => { 75 - return ( 76 - validate() === true ? "True" 77 - : validate() === false ? "False" 78 - : "Unset" 79 - ); 80 - }; 81 - 82 - createEffect(() => { 83 - if (openDialog()) { 84 - setValidate(undefined); 85 - setCollectionError(""); 86 - setRkeyError(""); 87 - } 88 - }); 89 - 90 - const createRecord = async (formData: FormData) => { 91 - const repo = formData.get("repo")?.toString(); 92 - if (!repo) return; 93 - const rpc = new Client({ handler: new OAuthUserAgent(await getSession(repo as Did)) }); 94 - const collection = formData.get("collection"); 95 - const rkey = formData.get("rkey"); 96 - let record: any; 97 - try { 98 - record = JSON.parse(editorInstance.view.state.doc.toString()); 99 - } catch (e: any) { 100 - setNotice(e.message); 101 - return; 102 - } 103 - const res = await rpc.post("com.atproto.repo.createRecord", { 104 - input: { 105 - repo: repo as Did, 106 - collection: collection ? collection.toString() : record.$type, 107 - rkey: rkey?.toString().length ? rkey?.toString() : undefined, 108 - record: record, 109 - validate: validate(), 110 - }, 111 - }); 112 - if (!res.ok) { 113 - setNotice(`${res.data.error}: ${res.data.message}`); 114 - return; 115 - } 116 - setOpenDialog(false); 117 - const id = addNotification({ 118 - message: "Record created", 119 - type: "success", 120 - }); 121 - setTimeout(() => removeNotification(id), 3000); 122 - navigate(`/${res.data.uri}`); 123 - }; 124 - 125 - const editRecord = async (recreate?: boolean) => { 126 - const record = editorInstance.view.state.doc.toString(); 127 - if (!record) return; 128 - const rpc = new Client({ handler: agent()! }); 129 - try { 130 - const editedRecord = JSON.parse(record); 131 - if (recreate) { 132 - const res = await rpc.post("com.atproto.repo.applyWrites", { 133 - input: { 134 - repo: agent()!.sub, 135 - validate: validate(), 136 - writes: [ 137 - { 138 - collection: params.collection as `${string}.${string}.${string}`, 139 - rkey: params.rkey!, 140 - $type: "com.atproto.repo.applyWrites#delete", 141 - }, 142 - { 143 - collection: params.collection as `${string}.${string}.${string}`, 144 - rkey: params.rkey, 145 - $type: "com.atproto.repo.applyWrites#create", 146 - value: editedRecord, 147 - }, 148 - ], 149 - }, 150 - }); 151 - if (!res.ok) { 152 - setNotice(`${res.data.error}: ${res.data.message}`); 153 - return; 154 - } 155 - } else { 156 - const res = await rpc.post("com.atproto.repo.putRecord", { 157 - input: { 158 - repo: agent()!.sub, 159 - collection: params.collection as `${string}.${string}.${string}`, 160 - rkey: params.rkey!, 161 - record: editedRecord, 162 - validate: validate(), 163 - }, 164 - }); 165 - if (!res.ok) { 166 - setNotice(`${res.data.error}: ${res.data.message}`); 167 - return; 168 - } 169 - } 170 - setOpenDialog(false); 171 - const id = addNotification({ 172 - message: "Record edited", 173 - type: "success", 174 - }); 175 - setTimeout(() => removeNotification(id), 3000); 176 - props.refetch(); 177 - } catch (err: any) { 178 - setNotice(err.message); 179 - } 180 - }; 181 - 182 - const insertTimestamp = () => { 183 - const timestamp = new Date().toISOString(); 184 - editorInstance.view.dispatch({ 185 - changes: { 186 - from: editorInstance.view.state.selection.main.head, 187 - insert: `"${timestamp}"`, 188 - }, 189 - }); 190 - setOpenInsertMenu(false); 191 - }; 192 - 193 - const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => { 194 - return ( 195 - <button 196 - type="button" 197 - class="flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 198 - onClick={props.onClick} 199 - > 200 - <span class={`iconify ${props.icon}`}></span> 201 - <span>{props.label}</span> 202 - </button> 203 - ); 204 - }; 205 - 206 - const FileUpload = (props: { file: File }) => { 207 - const [uploading, setUploading] = createSignal(false); 208 - const [error, setError] = createSignal(""); 209 - 210 - onCleanup(() => (blobInput.value = "")); 211 - 212 - const formatFileSize = (bytes: number) => { 213 - if (bytes === 0) return "0 Bytes"; 214 - const k = 1024; 215 - const sizes = ["Bytes", "KB", "MB", "GB"]; 216 - const i = Math.floor(Math.log(bytes) / Math.log(k)); 217 - return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 218 - }; 219 - 220 - const uploadBlob = async () => { 221 - let blob: Blob; 222 - 223 - const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 224 - (document.getElementById("mimetype") as HTMLInputElement).value = ""; 225 - if (mimetype) blob = new Blob([props.file], { type: mimetype }); 226 - else blob = props.file; 227 - 228 - if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 229 - const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 230 - if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); 231 - } 232 - 233 - const rpc = new Client({ handler: agent()! }); 234 - setUploading(true); 235 - const res = await rpc.post("com.atproto.repo.uploadBlob", { 236 - input: blob, 237 - }); 238 - setUploading(false); 239 - if (!res.ok) { 240 - setError(res.data.error); 241 - return; 242 - } 243 - editorInstance.view.dispatch({ 244 - changes: { 245 - from: editorInstance.view.state.selection.main.head, 246 - insert: JSON.stringify(res.data.blob, null, 2), 247 - }, 248 - }); 249 - setOpenUpload(false); 250 - }; 251 - 252 - return ( 253 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-[20rem] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 254 - <h2 class="mb-2 font-semibold">Upload blob</h2> 255 - <div class="flex flex-col gap-2 text-sm"> 256 - <div class="flex flex-col gap-1"> 257 - <p class="flex gap-1"> 258 - <span class="truncate">{props.file.name}</span> 259 - <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 260 - ({formatFileSize(props.file.size)}) 261 - </span> 262 - </p> 263 - </div> 264 - <div class="flex items-center gap-x-2"> 265 - <label for="mimetype" class="shrink-0 select-none"> 266 - MIME type 267 - </label> 268 - <TextInput id="mimetype" placeholder={props.file.type} /> 269 - </div> 270 - <div class="flex items-center gap-1"> 271 - <input id="exif-rm" type="checkbox" checked /> 272 - <label for="exif-rm" class="select-none"> 273 - Remove EXIF data 274 - </label> 275 - </div> 276 - <p class="text-xs text-neutral-600 dark:text-neutral-400"> 277 - Metadata will be pasted after the cursor 278 - </p> 279 - <Show when={error()}> 280 - <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 281 - </Show> 282 - <div class="flex justify-between gap-2"> 283 - <Button onClick={() => setOpenUpload(false)}>Cancel</Button> 284 - <Show when={uploading()}> 285 - <div class="flex items-center gap-1"> 286 - <span class="iconify lucide--loader-circle animate-spin"></span> 287 - <span>Uploading</span> 288 - </div> 289 - </Show> 290 - <Show when={!uploading()}> 291 - <Button 292 - onClick={uploadBlob} 293 - class="dark:shadow-dark-700 flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400" 294 - > 295 - Upload 296 - </Button> 297 - </Show> 298 - </div> 299 - </div> 300 - </div> 301 - ); 302 - }; 303 - 304 - return ( 305 - <> 306 - <Modal 307 - open={openDialog()} 308 - onClose={() => setOpenDialog(false)} 309 - closeOnClick={false} 310 - nonBlocking={isMinimized()} 311 - > 312 - <div 313 - style="transform: translateX(-50%) translateZ(0);" 314 - classList={{ 315 - "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto absolute top-18 left-1/2 flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-all duration-200 dark:border-neutral-700 starting:opacity-0": true, 316 - "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(), 317 - "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(), 318 - hidden: isMinimized(), 319 - }} 320 - > 321 - <div class="mb-2 flex w-full justify-between text-base"> 322 - <div class="flex items-center gap-2"> 323 - <span class="font-semibold select-none"> 324 - {props.create ? "Creating" : "Editing"} record 325 - </span> 326 - </div> 327 - <div class="flex items-center gap-1"> 328 - <button 329 - type="button" 330 - onclick={() => setIsMinimized(true)} 331 - class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 332 - > 333 - <span class="iconify lucide--minus"></span> 334 - </button> 335 - <button 336 - type="button" 337 - onclick={() => setIsMaximized(!isMaximized())} 338 - class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 339 - > 340 - <span 341 - class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 342 - ></span> 343 - </button> 344 - <button 345 - id="close" 346 - onclick={() => setOpenDialog(false)} 347 - class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 348 - > 349 - <span class="iconify lucide--x"></span> 350 - </button> 351 - </div> 352 - </div> 353 - <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 354 - <Show when={props.create}> 355 - <div class="flex flex-wrap items-center gap-1 text-sm"> 356 - <span>at://</span> 357 - <select 358 - class="dark:bg-dark-100 max-w-40 truncate rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 359 - name="repo" 360 - id="repo" 361 - > 362 - <For each={Object.keys(sessions)}> 363 - {(session) => ( 364 - <option value={session} selected={session === agent()?.sub}> 365 - {sessions[session].handle ?? session} 366 - </option> 367 - )} 368 - </For> 369 - </select> 370 - <span>/</span> 371 - <TextInput 372 - id="collection" 373 - name="collection" 374 - placeholder="Collection (default: $type)" 375 - class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 376 - onInput={(e) => { 377 - const value = e.currentTarget.value; 378 - if (!value || isNsid(value)) setCollectionError(""); 379 - else 380 - setCollectionError( 381 - "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", 382 - ); 383 - }} 384 - /> 385 - <span>/</span> 386 - <TextInput 387 - id="rkey" 388 - name="rkey" 389 - placeholder="Record key (default: TID)" 390 - class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 391 - onInput={(e) => { 392 - const value = e.currentTarget.value; 393 - if (!value || isRecordKey(value)) setRkeyError(""); 394 - else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); 395 - }} 396 - /> 397 - </div> 398 - <Show when={collectionError() || rkeyError()}> 399 - <div class="text-xs text-red-500 dark:text-red-400"> 400 - <div>{collectionError()}</div> 401 - <div>{rkeyError()}</div> 402 - </div> 403 - </Show> 404 - </Show> 405 - <div class="min-h-0 flex-1"> 406 - <Suspense 407 - fallback={ 408 - <div class="flex h-full items-center justify-center"> 409 - <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 410 - </div> 411 - } 412 - > 413 - <Editor 414 - content={JSON.stringify( 415 - !props.create ? props.record 416 - : params.rkey ? placeholder() 417 - : defaultPlaceholder(), 418 - null, 419 - 2, 420 - )} 421 - /> 422 - </Suspense> 423 - </div> 424 - <div class="flex flex-col gap-2"> 425 - <Show when={notice()}> 426 - <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 427 - </Show> 428 - <div class="flex justify-between gap-2"> 429 - <div class="relative" ref={insertMenuRef}> 430 - <button 431 - type="button" 432 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 flex w-fit rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 text-base shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 433 - onClick={() => setOpenInsertMenu(!openInsertMenu())} 434 - > 435 - <span class="iconify lucide--plus select-none"></span> 436 - </button> 437 - <Show when={openInsertMenu()}> 438 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 439 - <MenuItem 440 - icon="lucide--upload" 441 - label="Upload blob" 442 - onClick={() => { 443 - setOpenInsertMenu(false); 444 - blobInput.click(); 445 - }} 446 - /> 447 - <MenuItem 448 - icon="lucide--clock" 449 - label="Insert timestamp" 450 - onClick={insertTimestamp} 451 - /> 452 - </div> 453 - </Show> 454 - <input 455 - type="file" 456 - id="blob" 457 - class="sr-only" 458 - ref={blobInput} 459 - onChange={(e) => { 460 - if (e.target.files !== null) setOpenUpload(true); 461 - }} 462 - /> 463 - </div> 464 - <Modal 465 - open={openUpload()} 466 - onClose={() => setOpenUpload(false)} 467 - closeOnClick={false} 468 - > 469 - <FileUpload file={blobInput.files![0]} /> 470 - </Modal> 471 - <div class="flex items-center justify-end gap-2"> 472 - <button 473 - type="button" 474 - class="flex items-center gap-1 rounded-sm p-1.5 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 475 - onClick={() => 476 - setValidate( 477 - validate() === true ? false 478 - : validate() === false ? undefined 479 - : true, 480 - ) 481 - } 482 - > 483 - <Tooltip text={getValidateLabel()}> 484 - <span class={`iconify ${getValidateIcon()}`}></span> 485 - </Tooltip> 486 - <span>Validate</span> 487 - </button> 488 - <Show when={!props.create}> 489 - <Button onClick={() => editRecord(true)}>Recreate</Button> 490 - </Show> 491 - <Button 492 - onClick={() => 493 - props.create ? createRecord(new FormData(formRef)) : editRecord() 494 - } 495 - > 496 - {props.create ? "Create" : "Edit"} 497 - </Button> 498 - </div> 499 - </div> 500 - </div> 501 - </form> 502 - </div> 503 - </Modal> 504 - <Show when={isMinimized() && openDialog()}> 505 - <button 506 - class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 fixed right-4 bottom-4 z-30 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-2 shadow-md hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 507 - onclick={() => setIsMinimized(false)} 508 - > 509 - <span class="iconify lucide--square-pen text-lg"></span> 510 - <span class="text-sm font-medium">{props.create ? "Creating" : "Editing"} record</span> 511 - </button> 512 - </Show> 513 - <Tooltip text={`${props.create ? "Create" : "Edit"} record`}> 514 - <button 515 - class={`flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`} 516 - onclick={() => { 517 - setNotice(""); 518 - setOpenDialog(true); 519 - setIsMinimized(false); 520 - }} 521 - > 522 - <div 523 - class={props.create ? "iconify lucide--square-pen text-lg" : "iconify lucide--pencil"} 524 - /> 525 - </button> 526 - </Tooltip> 527 - </> 528 - ); 529 - };
+38 -12
src/components/dropdown.tsx
··· 10 10 Show, 11 11 useContext, 12 12 } from "solid-js"; 13 + import { Portal } from "solid-js/web"; 13 14 import { addToClipboard } from "../utils/copy"; 14 15 15 16 const MenuContext = createContext<{ ··· 102 103 const ctx = useContext(MenuContext); 103 104 const [menu, setMenu] = createSignal<HTMLDivElement>(); 104 105 const [menuButton, setMenuButton] = createSignal<HTMLButtonElement>(); 106 + const [buttonRect, setButtonRect] = createSignal<DOMRect>(); 105 107 106 108 const clickEvent = (event: MouseEvent) => { 107 109 const target = event.target as Node; 108 110 if (!menuButton()?.contains(target) && !menu()?.contains(target)) ctx?.setShowMenu(false); 109 111 }; 110 112 111 - onMount(() => window.addEventListener("click", clickEvent)); 112 - onCleanup(() => window.removeEventListener("click", clickEvent)); 113 + const updatePosition = () => { 114 + const rect = menuButton()?.getBoundingClientRect(); 115 + if (rect) setButtonRect(rect); 116 + }; 117 + 118 + onMount(() => { 119 + window.addEventListener("click", clickEvent); 120 + window.addEventListener("scroll", updatePosition, true); 121 + window.addEventListener("resize", updatePosition); 122 + }); 123 + 124 + onCleanup(() => { 125 + window.removeEventListener("click", clickEvent); 126 + window.removeEventListener("scroll", updatePosition, true); 127 + window.removeEventListener("resize", updatePosition); 128 + }); 113 129 114 130 return ( 115 131 <div class="relative"> ··· 119 135 props.buttonClass 120 136 } 121 137 ref={setMenuButton} 122 - onClick={() => ctx?.setShowMenu(!ctx?.showMenu())} 138 + onClick={() => { 139 + updatePosition(); 140 + ctx?.setShowMenu(!ctx?.showMenu()); 141 + }} 123 142 > 124 143 <span class={"iconify " + props.icon}></span> 125 144 </button> 126 145 <Show when={ctx?.showMenu()}> 127 - <div 128 - ref={setMenu} 129 - class={ 130 - "dark:bg-dark-300 dark:shadow-dark-700 absolute right-0 z-40 flex min-w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700 " + 131 - props.menuClass 132 - } 133 - > 134 - {props.children} 135 - </div> 146 + <Portal> 147 + <div 148 + ref={setMenu} 149 + style={{ 150 + position: "fixed", 151 + top: `${(buttonRect()?.bottom ?? 0) + 4}px`, 152 + left: `${(buttonRect()?.right ?? 0) - 160}px`, 153 + }} 154 + class={ 155 + "dark:bg-dark-300 dark:shadow-dark-700 z-50 flex min-w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-sm shadow-md dark:border-neutral-700 " + 156 + props.menuClass 157 + } 158 + > 159 + {props.children} 160 + </div> 161 + </Portal> 136 162 </Show> 137 163 </div> 138 164 );
+1 -1
src/components/editor.tsx
··· 7 7 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 8 import { basicSetup, EditorView } from "codemirror"; 9 9 import { onCleanup, onMount } from "solid-js"; 10 - import { editorInstance } from "./create"; 10 + import { editorInstance } from "./create/state"; 11 11 12 12 const Editor = (props: { content: string }) => { 13 13 let editorDiv!: HTMLDivElement;
-143
src/components/login.tsx
··· 1 - import { Client } from "@atcute/client"; 2 - import { Did } from "@atcute/lexicons"; 3 - import { isDid, isHandle } from "@atcute/lexicons/syntax"; 4 - import { 5 - configureOAuth, 6 - createAuthorizationUrl, 7 - defaultIdentityResolver, 8 - finalizeAuthorization, 9 - getSession, 10 - OAuthUserAgent, 11 - type Session, 12 - } from "@atcute/oauth-browser-client"; 13 - import { createSignal, Show } from "solid-js"; 14 - import { didDocumentResolver, handleResolver } from "../utils/api"; 15 - 16 - configureOAuth({ 17 - metadata: { 18 - client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 19 - redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 20 - }, 21 - identityResolver: defaultIdentityResolver({ 22 - handleResolver: handleResolver, 23 - didDocumentResolver: didDocumentResolver, 24 - }), 25 - }); 26 - 27 - export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 28 - 29 - type Account = { 30 - signedIn: boolean; 31 - handle?: string; 32 - }; 33 - 34 - export type Sessions = Record<string, Account>; 35 - 36 - const Login = () => { 37 - const [notice, setNotice] = createSignal(""); 38 - const [loginInput, setLoginInput] = createSignal(""); 39 - 40 - const login = async (handle: string) => { 41 - try { 42 - setNotice(""); 43 - if (!handle) return; 44 - setNotice(`Contacting your data server...`); 45 - const authUrl = await createAuthorizationUrl({ 46 - scope: import.meta.env.VITE_OAUTH_SCOPE, 47 - target: 48 - isHandle(handle) || isDid(handle) ? 49 - { type: "account", identifier: handle } 50 - : { type: "pds", serviceUrl: handle }, 51 - }); 52 - 53 - setNotice(`Redirecting...`); 54 - await new Promise((resolve) => setTimeout(resolve, 250)); 55 - 56 - location.assign(authUrl); 57 - } catch (e) { 58 - console.error(e); 59 - setNotice(`${e}`); 60 - } 61 - }; 62 - 63 - return ( 64 - <form class="flex flex-col gap-y-2 px-1" onsubmit={(e) => e.preventDefault()}> 65 - <label for="username" class="hidden"> 66 - Add account 67 - </label> 68 - <div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex grow items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400"> 69 - <label 70 - for="username" 71 - class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400" 72 - ></label> 73 - <input 74 - type="text" 75 - spellcheck={false} 76 - placeholder="user.bsky.social" 77 - id="username" 78 - name="username" 79 - autocomplete="username" 80 - aria-label="Your AT Protocol handle" 81 - class="grow py-1 select-none placeholder:text-sm focus:outline-none" 82 - onInput={(e) => setLoginInput(e.currentTarget.value)} 83 - /> 84 - <button 85 - onclick={() => login(loginInput())} 86 - class="flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 87 - > 88 - <span class="iconify lucide--log-in"></span> 89 - </button> 90 - </div> 91 - <Show when={notice()}> 92 - <div class="text-sm">{notice()}</div> 93 - </Show> 94 - </form> 95 - ); 96 - }; 97 - 98 - const retrieveSession = async () => { 99 - const init = async (): Promise<Session | undefined> => { 100 - const params = new URLSearchParams(location.hash.slice(1)); 101 - 102 - if (params.has("state") && (params.has("code") || params.has("error"))) { 103 - history.replaceState(null, "", location.pathname + location.search); 104 - 105 - const auth = await finalizeAuthorization(params); 106 - const did = auth.session.info.sub; 107 - 108 - localStorage.setItem("lastSignedIn", did); 109 - 110 - const sessions = localStorage.getItem("sessions"); 111 - const newSessions: Sessions = sessions ? JSON.parse(sessions) : { [did]: {} }; 112 - newSessions[did] = { signedIn: true }; 113 - localStorage.setItem("sessions", JSON.stringify(newSessions)); 114 - return auth.session; 115 - } else { 116 - const lastSignedIn = localStorage.getItem("lastSignedIn"); 117 - 118 - if (lastSignedIn) { 119 - const sessions = localStorage.getItem("sessions"); 120 - const newSessions: Sessions = sessions ? JSON.parse(sessions) : {}; 121 - try { 122 - const session = await getSession(lastSignedIn as Did); 123 - const rpc = new Client({ handler: new OAuthUserAgent(session) }); 124 - const res = await rpc.get("com.atproto.server.getSession"); 125 - newSessions[lastSignedIn].signedIn = true; 126 - localStorage.setItem("sessions", JSON.stringify(newSessions)); 127 - if (!res.ok) throw res.data.error; 128 - return session; 129 - } catch (err) { 130 - newSessions[lastSignedIn].signedIn = false; 131 - localStorage.setItem("sessions", JSON.stringify(newSessions)); 132 - throw err; 133 - } 134 - } 135 - } 136 - }; 137 - 138 - const session = await init(); 139 - 140 - if (session) setAgent(new OAuthUserAgent(session)); 141 - }; 142 - 143 - export { Login, retrieveSession };
+1 -1
src/components/notification.tsx
··· 36 36 37 37 export const NotificationContainer = () => { 38 38 return ( 39 - <div class="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2"> 39 + <div class="pointer-events-none fixed bottom-4 left-4 z-60 flex flex-col gap-2"> 40 40 <For each={notifications}> 41 41 {(notification) => ( 42 42 <div
+40 -13
src/components/search.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 2 import { Nsid } from "@atcute/lexicons"; 3 - import { A, useLocation, useNavigate } from "@solidjs/router"; 4 - import { createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 3 + import { A, useNavigate } from "@solidjs/router"; 4 + import { 5 + createEffect, 6 + createResource, 7 + createSignal, 8 + For, 9 + onCleanup, 10 + onMount, 11 + Show, 12 + } from "solid-js"; 5 13 import { isTouchDevice } from "../layout"; 6 14 import { resolveLexiconAuthority } from "../utils/api"; 7 15 import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls"; ··· 38 46 39 47 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") { 40 48 ev.preventDefault(); 41 - setShowSearch(!showSearch()); 49 + 50 + if (showSearch()) { 51 + const searchInput = document.querySelector("#input") as HTMLInputElement; 52 + if (searchInput && document.activeElement !== searchInput) { 53 + searchInput.focus(); 54 + } else { 55 + setShowSearch(false); 56 + } 57 + } else { 58 + setShowSearch(true); 59 + } 42 60 } else if (ev.key == "Escape") { 43 61 ev.preventDefault(); 44 62 setShowSearch(false); ··· 67 85 const navigate = useNavigate(); 68 86 let searchInput!: HTMLInputElement; 69 87 const rpc = new Client({ 70 - handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 88 + handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 71 89 }); 72 90 73 91 onMount(() => { 74 - if (useLocation().pathname !== "/") searchInput.focus(); 75 - 76 92 const handlePaste = (e: ClipboardEvent) => { 77 93 if (e.target === searchInput) return; 78 94 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; ··· 83 99 84 100 window.addEventListener("paste", handlePaste); 85 101 onCleanup(() => window.removeEventListener("paste", handlePaste)); 102 + }); 103 + 104 + createEffect(() => { 105 + if (showSearch()) searchInput.focus(); 86 106 }); 87 107 88 108 const fetchTypeahead = async (input: string) => { ··· 111 131 const currentInput = input(); 112 132 if (!currentInput) return SEARCH_PREFIXES; 113 133 114 - const { prefix } = parsePrefix(currentInput); 115 - if (prefix) return []; 134 + const { prefix, query } = parsePrefix(currentInput); 135 + if (prefix && query.length > 0) return []; 116 136 117 137 return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase())); 118 138 }; ··· 255 275 {(prefixItem, index) => ( 256 276 <button 257 277 type="button" 258 - class={`flex items-center rounded-lg p-2 ${ 278 + class={`flex items-center rounded-md p-2 ${ 259 279 index() === selectedIndex() ? 260 280 "bg-neutral-200 dark:bg-neutral-700" 261 281 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 280 300 const adjustedIndex = getPrefixSuggestions().length + index(); 281 301 return ( 282 302 <A 283 - class={`flex items-center gap-2 rounded-lg p-2 ${ 303 + class={`flex items-center gap-2 rounded-md p-2 ${ 284 304 adjustedIndex === selectedIndex() ? 285 305 "bg-neutral-200 dark:bg-neutral-700" 286 306 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 290 310 > 291 311 <img 292 312 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} 293 - class="size-8 rounded-full" 313 + class="size-9 rounded-full" 294 314 /> 295 - <span>{actor.handle}</span> 315 + <div class="flex flex-col"> 316 + <Show when={actor.displayName}> 317 + <span class="text-sm font-medium">{actor.displayName}</span> 318 + </Show> 319 + <span class="text-xs text-neutral-600 dark:text-neutral-400"> 320 + @{actor.handle} 321 + </span> 322 + </div> 296 323 </A> 297 324 ); 298 325 }}
+7 -1
src/components/video-player.tsx
··· 22 22 }); 23 23 24 24 return ( 25 - <video ref={video} class="max-h-80 max-w-[20rem]" controls playsinline onLoadedData={props.onLoad}> 25 + <video 26 + ref={video} 27 + class="max-h-80 max-w-[20rem]" 28 + controls 29 + playsinline 30 + onLoadedData={props.onLoad} 31 + > 26 32 <source type="video/mp4" /> 27 33 </video> 28 34 );
+23 -18
src/layout.tsx
··· 1 1 import { Handle } from "@atcute/lexicons"; 2 2 import { Meta, MetaProvider } from "@solidjs/meta"; 3 3 import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router"; 4 - import { createEffect, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 5 - import { AccountManager } from "./components/account.jsx"; 6 - import { RecordEditor } from "./components/create.jsx"; 4 + import { createEffect, ErrorBoundary, onCleanup, onMount, Show, Suspense } from "solid-js"; 5 + import { AccountManager } from "./auth/account.jsx"; 6 + import { hasUserScope } from "./auth/scope-utils"; 7 + import { agent } from "./auth/state.js"; 8 + import { RecordEditor } from "./components/create"; 7 9 import { DropdownMenu, MenuProvider, MenuSeparator, NavMenu } from "./components/dropdown.jsx"; 8 - import { agent } from "./components/login.jsx"; 9 10 import { NavBar } from "./components/navbar.jsx"; 10 11 import { NotificationContainer } from "./components/notification.jsx"; 11 12 import { Search, SearchButton, showSearch } from "./components/search.jsx"; ··· 38 39 createEffect(async () => { 39 40 if (props.params.repo && !props.params.repo.startsWith("did:")) { 40 41 const did = await resolveHandle(props.params.repo as Handle); 41 - navigate(location.pathname.replace(props.params.repo, did)); 42 + navigate(location.pathname.replace(props.params.repo, did), { replace: true }); 42 43 } 43 44 }); 44 45 45 46 onMount(() => { 46 47 window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", themeEvent); 47 48 49 + const handleGoToRepo = (ev: KeyboardEvent) => { 50 + if (document.querySelector("[data-modal]")) return; 51 + if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; 52 + 53 + if (ev.key === "g" && agent()?.sub) { 54 + ev.preventDefault(); 55 + navigate(`/at://${agent()!.sub}`); 56 + } 57 + }; 58 + 59 + window.addEventListener("keydown", handleGoToRepo); 60 + onCleanup(() => window.removeEventListener("keydown", handleGoToRepo)); 61 + 48 62 if (localStorage.getItem("sailor") === "true") { 49 63 const style = document.createElement("style"); 50 64 style.textContent = ` ··· 104 118 }); 105 119 106 120 return ( 107 - <div 108 - id="main" 109 - class="mx-auto mb-8 flex max-w-lg flex-col items-center p-4 text-neutral-900 dark:text-neutral-200" 110 - > 121 + <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-4"> 111 122 <MetaProvider> 112 123 <Show when={location.pathname !== "/"}> 113 124 <Meta name="robots" content="noindex, nofollow" /> ··· 131 142 <span>PDSls</span> 132 143 </A> 133 144 <div class="dark:bg-dark-300/60 relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 px-1 py-0.5"> 134 - <Show when={location.pathname !== "/"}> 135 - <SearchButton /> 136 - </Show> 137 - <Show when={agent()}> 145 + <SearchButton /> 146 + <Show when={hasUserScope("create")}> 138 147 <RecordEditor create={true} /> 139 148 </Show> 140 149 <AccountManager /> 141 150 <MenuProvider> 142 - <DropdownMenu 143 - icon="lucide--menu text-lg" 144 - buttonClass="rounded-lg p-1.5" 145 - menuClass="top-11 text-sm" 146 - > 151 + <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-lg p-1.5"> 147 152 <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 148 153 <NavMenu href="/firehose" label="Firehose" icon="lucide--droplet" /> 149 154 <NavMenu href="/labels" label="Labels" icon="lucide--tags" />
+7 -1
src/styles/index.css
··· 6 6 7 7 @custom-variant dark (&:where(.dark, .dark *)); 8 8 9 + @font-face { 10 + font-family: "Figtree"; 11 + src: url("/fonts/Figtree[wght].woff2") format("woff2"); 12 + font-display: swap; 13 + } 14 + 9 15 @theme { 10 - --font-sans: "Inter", sans-serif; 16 + --font-sans: "Figtree", sans-serif; 11 17 --font-mono: "Roboto Mono", monospace; 12 18 --font-pecita: "Pecita", serif; 13 19
+15 -15
src/utils/hooks/debounced.ts
··· 1 - import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js'; 1 + import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js"; 2 2 3 3 export const createDebouncedValue = <T>( 4 - accessor: Accessor<T>, 5 - delay: number, 6 - equals?: false | ((prev: T, next: T) => boolean), 4 + accessor: Accessor<T>, 5 + delay: number, 6 + equals?: false | ((prev: T, next: T) => boolean), 7 7 ): Accessor<T> => { 8 - const initial = accessor(); 9 - const [state, setState] = createSignal(initial, { equals }); 8 + const initial = accessor(); 9 + const [state, setState] = createSignal(initial, { equals }); 10 10 11 - createEffect((prev: T) => { 12 - const next = accessor(); 11 + createEffect((prev: T) => { 12 + const next = accessor(); 13 13 14 - if (prev !== next) { 15 - const timeout = setTimeout(() => setState(() => next), delay); 16 - onCleanup(() => clearTimeout(timeout)); 17 - } 14 + if (prev !== next) { 15 + const timeout = setTimeout(() => setState(() => next), delay); 16 + onCleanup(() => clearTimeout(timeout)); 17 + } 18 18 19 - return next; 20 - }, initial); 19 + return next; 20 + }, initial); 21 21 22 - return state; 22 + return state; 23 23 };
+30
src/utils/key.ts
··· 1 + import { parseDidKey, parsePublicMultikey } from "@atcute/crypto"; 2 + import { fromBase58Btc } from "@atcute/multibase"; 3 + 4 + export const detectKeyType = (key: string): string => { 5 + try { 6 + return parsePublicMultikey(key).type; 7 + } catch (e) { 8 + try { 9 + const bytes = fromBase58Btc(key.startsWith("z") ? key.slice(1) : key); 10 + if (bytes.length >= 2) { 11 + const type = (bytes[0] << 8) | bytes[1]; 12 + if (type === 0xed01) { 13 + return "ed25519"; 14 + } 15 + } 16 + } catch {} 17 + return "unknown"; 18 + } 19 + }; 20 + 21 + export const detectDidKeyType = (key: string): string => { 22 + try { 23 + return parseDidKey(key).type; 24 + } catch (e) { 25 + if (key.startsWith("did:key:")) { 26 + return detectKeyType(key.slice(8)); 27 + } 28 + return "unknown"; 29 + } 30 + };
+2 -2
src/views/blob.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 2 import { createResource, createSignal, For, Show } from "solid-js"; 3 3 import { Button } from "../components/button"; 4 4 ··· 9 9 let rpc: Client; 10 10 11 11 const fetchBlobs = async () => { 12 - if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: props.pds }) }); 12 + if (!rpc) rpc = new Client({ handler: simpleFetchHandler({ service: props.pds }) }); 13 13 const res = await rpc.get("com.atproto.sync.listBlobs", { 14 14 params: { 15 15 did: props.repo as `did:${string}:${string}`,
+21 -18
src/views/collection.tsx
··· 1 1 import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto"; 2 - import { Client, CredentialManager } from "@atcute/client"; 2 + import { Client, simpleFetchHandler } from "@atcute/client"; 3 3 import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons"; 4 4 import * as TID from "@atcute/tid"; 5 5 import { A, useParams } from "@solidjs/router"; 6 6 import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; 7 7 import { createStore } from "solid-js/store"; 8 + import { hasUserScope } from "../auth/scope-utils"; 9 + import { agent } from "../auth/state"; 8 10 import { Button } from "../components/button.jsx"; 9 11 import { JSONType, JSONValue } from "../components/json.jsx"; 10 - import { agent } from "../components/login.jsx"; 11 12 import { Modal } from "../components/modal.jsx"; 12 13 import { addNotification, removeNotification } from "../components/notification.jsx"; 13 14 import { StickyOverlay } from "../components/sticky.jsx"; ··· 88 89 89 90 const fetchRecords = async () => { 90 91 if (!pds) pds = await resolvePDS(did!); 91 - if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 92 + if (!rpc) rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 92 93 const res = await rpc.get("com.atproto.repo.listRecords", { 93 94 params: { 94 95 repo: did as ActorIdentifier, ··· 198 199 <StickyOverlay> 199 200 <div class="flex w-full flex-col gap-2"> 200 201 <div class="flex items-center gap-1"> 201 - <Show when={agent() && agent()?.sub === did}> 202 + <Show when={agent() && agent()?.sub === did && hasUserScope("delete")}> 202 203 <div class="flex items-center"> 203 204 <Tooltip 204 205 text={batchDelete() ? "Cancel" : "Delete"} ··· 229 230 </button> 230 231 } 231 232 /> 232 - <Tooltip 233 - text="Recreate" 234 - children={ 235 - <button 236 - onclick={() => { 237 - setRecreate(true); 238 - setOpenDelete(true); 239 - }} 240 - class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 241 - > 242 - <span class="iconify lucide--recycle text-lg text-green-500 dark:text-green-400"></span> 243 - </button> 244 - } 245 - /> 233 + <Show when={hasUserScope("create")}> 234 + <Tooltip 235 + text="Recreate" 236 + children={ 237 + <button 238 + onclick={() => { 239 + setRecreate(true); 240 + setOpenDelete(true); 241 + }} 242 + class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 243 + > 244 + <span class="iconify lucide--recycle text-lg text-green-500 dark:text-green-400"></span> 245 + </button> 246 + } 247 + /> 248 + </Show> 246 249 <Tooltip 247 250 text="Delete" 248 251 children={
+19 -21
src/views/labels.tsx
··· 1 1 import { ComAtprotoLabelDefs } from "@atcute/atproto"; 2 - import { Client, CredentialManager } from "@atcute/client"; 2 + import { Client, simpleFetchHandler } from "@atcute/client"; 3 3 import { isAtprotoDid } from "@atcute/identity"; 4 4 import { Handle } from "@atcute/lexicons"; 5 5 import { A, useSearchParams } from "@solidjs/router"; ··· 17 17 18 18 return ( 19 19 <div class="flex flex-col gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-3 dark:border-neutral-700 dark:bg-neutral-800"> 20 - <div class="flex flex-wrap items-center gap-x-2 gap-y-2"> 21 - <div class="inline-flex items-center gap-x-1 text-sm font-medium"> 22 - <span class="iconify lucide--tag shrink-0" /> 23 - {label.val} 24 - </div> 25 - <Show when={label.neg}> 26 - <div class="inline-flex items-center gap-x-1 text-xs font-medium text-red-500 dark:text-red-400"> 27 - <span>negated</span> 28 - </div> 29 - </Show> 30 - <div class="flex flex-wrap gap-3 text-xs text-neutral-600 dark:text-neutral-400"> 31 - <span>{localDateFromTimestamp(new Date(label.cts).getTime())}</span> 32 - <Show when={label.exp}> 33 - {(exp) => ( 34 - <div class="flex items-center gap-x-1"> 35 - <span class="iconify lucide--clock-fading shrink-0" /> 36 - <span>{localDateFromTimestamp(new Date(exp()).getTime())}</span> 37 - </div> 38 - )} 20 + <div class="flex gap-1 text-sm"> 21 + <span class="iconify lucide--tag shrink-0 self-center" /> 22 + <div class="flex flex-wrap items-baseline gap-2"> 23 + <span class="font-medium">{label.val}</span> 24 + <Show when={label.neg}> 25 + <span class="text-xs font-medium text-red-500 dark:text-red-400">negated</span> 39 26 </Show> 27 + <div class="flex flex-wrap gap-2 text-xs text-neutral-600 dark:text-neutral-400"> 28 + <span>{localDateFromTimestamp(new Date(label.cts).getTime())}</span> 29 + <Show when={label.exp}> 30 + {(exp) => ( 31 + <div class="flex items-center gap-x-1"> 32 + <span class="iconify lucide--clock-fading shrink-0" /> 33 + <span>{localDateFromTimestamp(new Date(exp()).getTime())}</span> 34 + </div> 35 + )} 36 + </Show> 37 + </div> 40 38 </div> 41 39 </div> 42 40 ··· 160 158 await resolvePDS(did); 161 159 if (!labelerCache[did]) throw new Error("Repository is not a labeler"); 162 160 rpc = new Client({ 163 - handler: new CredentialManager({ service: labelerCache[did] }), 161 + handler: simpleFetchHandler({ service: labelerCache[did] }), 164 162 }); 165 163 166 164 setSearchParams({ did, uriPatterns });
+3 -7
src/views/pds.tsx
··· 1 1 import { ComAtprotoServerDescribeServer, ComAtprotoSyncListRepos } from "@atcute/atproto"; 2 - import { Client, CredentialManager } from "@atcute/client"; 2 + import { Client, simpleFetchHandler } from "@atcute/client"; 3 3 import { InferXRPCBodyOutput } from "@atcute/lexicons"; 4 4 import * as TID from "@atcute/tid"; 5 5 import { A, useLocation, useParams } from "@solidjs/router"; ··· 23 23 setPDS(params.pds); 24 24 const pds = 25 25 params.pds!.startsWith("localhost") ? `http://${params.pds}` : `https://${params.pds}`; 26 - const rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 26 + const rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 27 27 28 28 const getVersion = async () => { 29 29 // @ts-expect-error: undocumented endpoint ··· 145 145 <Tab tab="info" label="Info" /> 146 146 </div> 147 147 <MenuProvider> 148 - <DropdownMenu 149 - icon="lucide--ellipsis-vertical" 150 - buttonClass="rounded-sm p-1.5" 151 - menuClass="top-9 text-sm" 152 - > 148 + <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5"> 153 149 <CopyMenu content={params.pds!} label="Copy PDS" icon="lucide--copy" /> 154 150 <NavMenu 155 151 href={`/firehose?instance=wss://${params.pds}`}
+33 -32
src/views/record.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 2 import { DidDocument, getPdsEndpoint } from "@atcute/identity"; 3 3 import { lexiconDoc } from "@atcute/lexicon-doc"; 4 4 import { RecordValidator } from "@atcute/lexicon-doc/validations"; ··· 8 8 import { verifyRecord } from "@atcute/repo"; 9 9 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 10 10 import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 11 + import { hasUserScope } from "../auth/scope-utils"; 12 + import { agent } from "../auth/state"; 11 13 import { Backlinks } from "../components/backlinks.jsx"; 12 14 import { Button } from "../components/button.jsx"; 13 - import { RecordEditor, setPlaceholder } from "../components/create.jsx"; 15 + import { RecordEditor, setPlaceholder } from "../components/create"; 14 16 import { 15 17 CopyMenu, 16 18 DropdownMenu, ··· 20 22 } from "../components/dropdown.jsx"; 21 23 import { JSONValue } from "../components/json.jsx"; 22 24 import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 23 - import { agent } from "../components/login.jsx"; 24 25 import { Modal } from "../components/modal.jsx"; 25 26 import { pds } from "../components/navbar.jsx"; 26 27 import { addNotification, removeNotification } from "../components/notification.jsx"; ··· 67 68 }); 68 69 } 69 70 70 - const rpc = new Client({ handler: new CredentialManager({ service: pdsEndpoint }) }); 71 + const rpc = new Client({ handler: simpleFetchHandler({ service: pdsEndpoint }) }); 71 72 const response = await rpc.get("com.atproto.repo.getRecord", { 72 73 params: { 73 74 repo: authority, ··· 207 208 setValidSchema(undefined); 208 209 setLexiconUri(undefined); 209 210 const pds = await resolvePDS(did!); 210 - rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 211 + rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 211 212 const res = await rpc.get("com.atproto.repo.getRecord", { 212 213 params: { 213 214 repo: did as ActorIdentifier, ··· 389 390 </div> 390 391 <div class="flex gap-0.5"> 391 392 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 392 - <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 393 - <Tooltip text="Delete"> 394 - <button 395 - class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 396 - onclick={() => setOpenDelete(true)} 397 - > 398 - <span class="iconify lucide--trash-2"></span> 399 - </button> 400 - </Tooltip> 401 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 402 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 403 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 404 - <div class="flex justify-end gap-2"> 405 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 406 - <Button 407 - onClick={deleteRecord} 408 - class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 409 - > 410 - Delete 411 - </Button> 393 + <Show when={hasUserScope("update")}> 394 + <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 395 + </Show> 396 + <Show when={hasUserScope("delete")}> 397 + <Tooltip text="Delete"> 398 + <button 399 + class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 400 + onclick={() => setOpenDelete(true)} 401 + > 402 + <span class="iconify lucide--trash-2"></span> 403 + </button> 404 + </Tooltip> 405 + <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 406 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 407 + <h2 class="mb-2 font-semibold">Delete this record?</h2> 408 + <div class="flex justify-end gap-2"> 409 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 410 + <Button 411 + onClick={deleteRecord} 412 + class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 413 + > 414 + Delete 415 + </Button> 416 + </div> 412 417 </div> 413 - </div> 414 - </Modal> 418 + </Modal> 419 + </Show> 415 420 </Show> 416 421 <MenuProvider> 417 - <DropdownMenu 418 - icon="lucide--ellipsis-vertical" 419 - buttonClass="rounded-sm p-1.5" 420 - menuClass="top-9 text-sm" 421 - > 422 + <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5"> 422 423 <CopyMenu 423 424 content={JSON.stringify(record()?.value, null, 2)} 424 425 label="Copy record"
+42 -44
src/views/repo.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 2 - import { parseDidKey, parsePublicMultikey } from "@atcute/crypto"; 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 3 2 import { DidDocument } from "@atcute/identity"; 4 3 import { ActorIdentifier, Did, Handle, Nsid } from "@atcute/lexicons"; 5 4 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; ··· 39 38 resolvePDS, 40 39 validateHandle, 41 40 } from "../utils/api.js"; 41 + import { detectDidKeyType, detectKeyType } from "../utils/key.js"; 42 42 import { BlobView } from "./blob.jsx"; 43 43 import { PlcLogView } from "./logs.jsx"; 44 44 ··· 113 113 if (!did.startsWith("did:")) { 114 114 try { 115 115 const did = await resolveHandle(params.repo as Handle); 116 - navigate(location.pathname.replace(params.repo!, did)); 116 + navigate(location.pathname.replace(params.repo!, did), { replace: true }); 117 117 return; 118 118 } catch { 119 119 try { 120 120 const nsid = params.repo as Nsid; 121 121 const res = await resolveLexiconAuthority(nsid); 122 - navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); 122 + navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`, { replace: true }); 123 123 return; 124 124 } catch { 125 - navigate(`/${did}`); 125 + navigate(`/${did}`, { replace: true }); 126 126 return; 127 127 } 128 128 } ··· 139 139 return {}; 140 140 } 141 141 142 - rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 143 - const res = await rpc.get("com.atproto.repo.describeRepo", { 144 - params: { repo: did as ActorIdentifier }, 145 - }); 146 - if (res.ok) { 147 - const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 148 - res.data.collections.forEach((c) => { 149 - const nsid = c.split("."); 150 - if (nsid.length > 2) { 151 - const authority = `${nsid[0]}.${nsid[1]}`; 152 - collections[authority] = { 153 - nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 154 - hidden: false, 155 - }; 156 - } 142 + rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 143 + try { 144 + const res = await rpc.get("com.atproto.repo.describeRepo", { 145 + params: { repo: did as ActorIdentifier }, 157 146 }); 158 - setNsids(collections); 159 - } else { 160 - console.error(res.data.error); 161 - switch (res.data.error) { 162 - case "RepoDeactivated": 163 - setError("Deactivated"); 164 - break; 165 - case "RepoTakendown": 166 - setError("Takendown"); 167 - break; 168 - default: 169 - setError("Unreachable"); 147 + if (res.ok) { 148 + const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 149 + res.data.collections.forEach((c) => { 150 + const nsid = c.split("."); 151 + if (nsid.length > 2) { 152 + const authority = `${nsid[0]}.${nsid[1]}`; 153 + collections[authority] = { 154 + nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 155 + hidden: false, 156 + }; 157 + } 158 + }); 159 + setNsids(collections); 160 + } else { 161 + console.error(res.data.error); 162 + switch (res.data.error) { 163 + case "RepoDeactivated": 164 + setError("Deactivated"); 165 + break; 166 + case "RepoTakendown": 167 + setError("Takendown"); 168 + break; 169 + default: 170 + setError("Unreachable"); 171 + } 170 172 } 173 + 174 + return res.data; 175 + } catch { 176 + return {}; 171 177 } 172 - 173 - return res.data; 174 178 }; 175 179 176 180 const [repo] = createResource(fetchRepo); ··· 303 307 </Tooltip> 304 308 </Show> 305 309 <MenuProvider> 306 - <DropdownMenu 307 - icon="lucide--ellipsis-vertical" 308 - buttonClass="rounded-sm p-1.5" 309 - menuClass="top-9 text-sm" 310 - > 310 + <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5"> 311 311 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" /> 312 312 <NavMenu 313 313 href={`/jetstream?dids=${params.repo}`} ··· 478 478 <Show when={location.hash === "#identity" || (error() && !location.hash)}> 479 479 <Show when={didDoc()}> 480 480 {(didDocument) => ( 481 - <div class="flex flex-col gap-2 wrap-anywhere"> 481 + <div class="flex flex-col gap-3 wrap-anywhere"> 482 482 {/* ID Section */} 483 483 <div> 484 484 <div class="flex items-center gap-1"> ··· 570 570 #{verif.id.split("#")[1]} 571 571 </span> 572 572 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"> 573 - <ErrorBoundary fallback={<>unknown</>}> 574 - {parsePublicMultikey(key()).type} 575 - </ErrorBoundary> 573 + {detectKeyType(key())} 576 574 </span> 577 575 </div> 578 576 <div class="font-mono break-all">{key()}</div> ··· 596 594 {(key) => ( 597 595 <div class="text-sm"> 598 596 <span class="rounded bg-neutral-200 px-1 py-0.5 text-xs text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"> 599 - {parseDidKey(key).type} 597 + {detectDidKeyType(key)} 600 598 </span> 601 599 <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 602 600 </div>