atproto explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Backlinks support (#21)

resolves #17
opt-in backlinks support and settings page

---------

Co-authored-by: phil <uniphil@gmail.com>

authored by

Juliet
phil
and committed by
GitHub
83d7e908 4870713d

+618 -111
+3 -3
package.json
··· 9 9 "serve": "vite preview" 10 10 }, 11 11 "devDependencies": { 12 - "prettier": "^3.5.1", 12 + "prettier": "^3.5.2", 13 13 "prettier-plugin-tailwindcss": "^0.6.11", 14 14 "typescript": "^5.7.3", 15 15 "unocss": "^66.0.0", ··· 20 20 "dependencies": { 21 21 "@atcute/car": "^2.0.3", 22 22 "@atcute/cbor": "^2.1.3", 23 - "@atcute/client": "^2.0.7", 23 + "@atcute/client": "^2.0.8", 24 24 "@atcute/oauth-browser-client": "^1.0.13", 25 25 "@atcute/tid": "^1.0.2", 26 26 "@solidjs/meta": "^0.29.4", ··· 28 28 "hls.js": "^1.5.20", 29 29 "monaco-editor": "^0.52.2", 30 30 "public-transport": "file:pkg/pt.tgz", 31 - "solid-js": "^1.9.4" 31 + "solid-js": "^1.9.5" 32 32 }, 33 33 "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" 34 34 }
+43 -43
pnpm-lock.yaml
··· 15 15 specifier: ^2.1.3 16 16 version: 2.1.3 17 17 '@atcute/client': 18 - specifier: ^2.0.7 19 - version: 2.0.7 18 + specifier: ^2.0.8 19 + version: 2.0.8 20 20 '@atcute/oauth-browser-client': 21 21 specifier: ^1.0.13 22 22 version: 1.0.13 ··· 25 25 version: 1.0.2 26 26 '@solidjs/meta': 27 27 specifier: ^0.29.4 28 - version: 0.29.4(solid-js@1.9.4) 28 + version: 0.29.4(solid-js@1.9.5) 29 29 '@solidjs/router': 30 30 specifier: ^0.15.3 31 - version: 0.15.3(solid-js@1.9.4) 31 + version: 0.15.3(solid-js@1.9.5) 32 32 hls.js: 33 33 specifier: ^1.5.20 34 34 version: 1.5.20 ··· 39 39 specifier: file:pkg/pt.tgz 40 40 version: file:pkg/pt.tgz 41 41 solid-js: 42 - specifier: ^1.9.4 43 - version: 1.9.4 42 + specifier: ^1.9.5 43 + version: 1.9.5 44 44 devDependencies: 45 45 prettier: 46 - specifier: ^3.5.1 47 - version: 3.5.1 46 + specifier: ^3.5.2 47 + version: 3.5.2 48 48 prettier-plugin-tailwindcss: 49 49 specifier: ^0.6.11 50 - version: 0.6.11(prettier@3.5.1) 50 + version: 0.6.11(prettier@3.5.2) 51 51 typescript: 52 52 specifier: ^5.7.3 53 53 version: 5.7.3 ··· 59 59 version: 6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2) 60 60 vite-plugin-solid: 61 61 specifier: ^2.11.2 62 - version: 2.11.2(solid-js@1.9.4)(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)) 62 + version: 2.11.2(solid-js@1.9.5)(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)) 63 63 vite-plugin-wasm: 64 64 specifier: ^3.4.1 65 65 version: 3.4.1(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)) ··· 85 85 '@atcute/cid@2.1.0': 86 86 resolution: {integrity: sha512-Twsf5OKGk6QEqU1Z74feo1eRmnznYFzdCxtCISCutedCL2I2eGzD1F7JZRA+heTp5kgV5Bwvxvjn7VkGQhq3Sg==} 87 87 88 - '@atcute/client@2.0.7': 89 - resolution: {integrity: sha512-bvNahrCGvhZw/EIx0HU/GOoKZEnUaAppbuZh7cu+VsOFA2tdFLnZJed9Hagh5Yz/eUX7QUh5NB4dRTRUdggSLQ==} 88 + '@atcute/client@2.0.8': 89 + resolution: {integrity: sha512-OTfiWwjB4mOTlp2InGStvoQ+PIA5lvih9cTYU8BvOhzNcCBUpt4l860MKZExHjvQ9Tt1kjq/ED9zRiUjsAgIxw==} 90 90 91 91 '@atcute/multibase@1.1.2': 92 92 resolution: {integrity: sha512-KFX+c7a/u2jSNcRw0rLaUHG/XEKf1A1c8XF5soHnsb1JMCShihf/anfZ1kJ4no/IlIp9HEHV3PQRQO2sWL6ASQ==} ··· 745 745 resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} 746 746 engines: {node: '>= 8'} 747 747 748 - babel-plugin-jsx-dom-expressions@0.39.6: 749 - resolution: {integrity: sha512-HMkTn5A3NyydEgG7HKmm48YcnsQQyqeT6SKNWh2TrS6nn5rOLeHDfg5hPbrRUCFUqaT9WGn5NInQfMc3qne3Dg==} 748 + babel-plugin-jsx-dom-expressions@0.39.7: 749 + resolution: {integrity: sha512-8GzVmFla7jaTNWW8W+lTMl9YGva4/06CtwJjySnkYtt8G1v9weCzc2SuF1DfrudcCNb2Doetc1FRg33swBYZCA==} 750 750 peerDependencies: 751 751 '@babel/core': ^7.20.12 752 752 753 - babel-preset-solid@1.9.3: 754 - resolution: {integrity: sha512-jvlx5wDp8s+bEF9sGFw/84SInXOA51ttkUEroQziKMbxplXThVKt83qB6bDTa1HuLNatdU9FHpFOiQWs1tLQIg==} 753 + babel-preset-solid@1.9.5: 754 + resolution: {integrity: sha512-85I3osODJ1LvZbv8wFozROV1vXq32BubqHXAGu73A//TRs3NLI1OFP83AQBUTSQHwgZQmARjHlJciym3we+V+w==} 755 755 peerDependencies: 756 756 '@babel/core': ^7.0.0 757 757 ··· 817 817 duplexer@0.1.2: 818 818 resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} 819 819 820 - electron-to-chromium@1.5.102: 821 - resolution: {integrity: sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==} 820 + electron-to-chromium@1.5.103: 821 + resolution: {integrity: sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==} 822 822 823 823 entities@4.5.0: 824 824 resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} ··· 1060 1060 prettier-plugin-svelte: 1061 1061 optional: true 1062 1062 1063 - prettier@3.5.1: 1064 - resolution: {integrity: sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==} 1063 + prettier@3.5.2: 1064 + resolution: {integrity: sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==} 1065 1065 engines: {node: '>=14'} 1066 1066 hasBin: true 1067 1067 ··· 1099 1099 resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} 1100 1100 engines: {node: '>=18'} 1101 1101 1102 - solid-js@1.9.4: 1103 - resolution: {integrity: sha512-ipQl8FJ31bFUoBNScDQTG3BjN6+9Rg+Q+f10bUbnO6EOTTf5NGerJeHc7wyu5I4RMHEl/WwZwUmy/PTRgxxZ8g==} 1102 + solid-js@1.9.5: 1103 + resolution: {integrity: sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==} 1104 1104 1105 1105 solid-refresh@0.6.3: 1106 1106 resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} ··· 1281 1281 '@atcute/uint8array': 1.0.1 1282 1282 '@atcute/varint': 1.0.2 1283 1283 1284 - '@atcute/client@2.0.7': {} 1284 + '@atcute/client@2.0.8': {} 1285 1285 1286 1286 '@atcute/multibase@1.1.2': 1287 1287 dependencies: ··· 1289 1289 1290 1290 '@atcute/oauth-browser-client@1.0.13': 1291 1291 dependencies: 1292 - '@atcute/client': 2.0.7 1292 + '@atcute/client': 2.0.8 1293 1293 1294 1294 '@atcute/tid@1.0.2': {} 1295 1295 ··· 1644 1644 '@rollup/rollup-win32-x64-msvc@4.34.8': 1645 1645 optional: true 1646 1646 1647 - '@solidjs/meta@0.29.4(solid-js@1.9.4)': 1647 + '@solidjs/meta@0.29.4(solid-js@1.9.5)': 1648 1648 dependencies: 1649 - solid-js: 1.9.4 1649 + solid-js: 1.9.5 1650 1650 1651 - '@solidjs/router@0.15.3(solid-js@1.9.4)': 1651 + '@solidjs/router@0.15.3(solid-js@1.9.5)': 1652 1652 dependencies: 1653 - solid-js: 1.9.4 1653 + solid-js: 1.9.5 1654 1654 1655 1655 '@types/babel__core@7.20.5': 1656 1656 dependencies: ··· 1886 1886 normalize-path: 3.0.0 1887 1887 picomatch: 2.3.1 1888 1888 1889 - babel-plugin-jsx-dom-expressions@0.39.6(@babel/core@7.26.9): 1889 + babel-plugin-jsx-dom-expressions@0.39.7(@babel/core@7.26.9): 1890 1890 dependencies: 1891 1891 '@babel/core': 7.26.9 1892 1892 '@babel/helper-module-imports': 7.18.6 ··· 1896 1896 parse5: 7.2.1 1897 1897 validate-html-nesting: 1.2.2 1898 1898 1899 - babel-preset-solid@1.9.3(@babel/core@7.26.9): 1899 + babel-preset-solid@1.9.5(@babel/core@7.26.9): 1900 1900 dependencies: 1901 1901 '@babel/core': 7.26.9 1902 - babel-plugin-jsx-dom-expressions: 0.39.6(@babel/core@7.26.9) 1902 + babel-plugin-jsx-dom-expressions: 0.39.7(@babel/core@7.26.9) 1903 1903 1904 1904 binary-extensions@2.3.0: {} 1905 1905 ··· 1910 1910 browserslist@4.24.4: 1911 1911 dependencies: 1912 1912 caniuse-lite: 1.0.30001700 1913 - electron-to-chromium: 1.5.102 1913 + electron-to-chromium: 1.5.103 1914 1914 node-releases: 2.0.19 1915 1915 update-browserslist-db: 1.1.2(browserslist@4.24.4) 1916 1916 ··· 1955 1955 1956 1956 duplexer@0.1.2: {} 1957 1957 1958 - electron-to-chromium@1.5.102: {} 1958 + electron-to-chromium@1.5.103: {} 1959 1959 1960 1960 entities@4.5.0: {} 1961 1961 ··· 2151 2151 picocolors: 1.1.1 2152 2152 source-map-js: 1.2.1 2153 2153 2154 - prettier-plugin-tailwindcss@0.6.11(prettier@3.5.1): 2154 + prettier-plugin-tailwindcss@0.6.11(prettier@3.5.2): 2155 2155 dependencies: 2156 - prettier: 3.5.1 2156 + prettier: 3.5.2 2157 2157 2158 - prettier@3.5.1: {} 2158 + prettier@3.5.2: {} 2159 2159 2160 2160 public-transport@file:pkg/pt.tgz: {} 2161 2161 ··· 2205 2205 mrmime: 2.0.1 2206 2206 totalist: 3.0.1 2207 2207 2208 - solid-js@1.9.4: 2208 + solid-js@1.9.5: 2209 2209 dependencies: 2210 2210 csstype: 3.1.3 2211 2211 seroval: 1.2.1 2212 2212 seroval-plugins: 1.2.1(seroval@1.2.1) 2213 2213 2214 - solid-refresh@0.6.3(solid-js@1.9.4): 2214 + solid-refresh@0.6.3(solid-js@1.9.5): 2215 2215 dependencies: 2216 2216 '@babel/generator': 7.26.9 2217 2217 '@babel/helper-module-imports': 7.25.9 2218 2218 '@babel/types': 7.26.9 2219 - solid-js: 1.9.4 2219 + solid-js: 1.9.5 2220 2220 transitivePeerDependencies: 2221 2221 - supports-color 2222 2222 ··· 2296 2296 2297 2297 validate-html-nesting@1.2.2: {} 2298 2298 2299 - vite-plugin-solid@2.11.2(solid-js@1.9.4)(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)): 2299 + vite-plugin-solid@2.11.2(solid-js@1.9.5)(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)): 2300 2300 dependencies: 2301 2301 '@babel/core': 7.26.9 2302 2302 '@types/babel__core': 7.20.5 2303 - babel-preset-solid: 1.9.3(@babel/core@7.26.9) 2303 + babel-preset-solid: 1.9.5(@babel/core@7.26.9) 2304 2304 merge-anything: 5.1.7 2305 - solid-js: 1.9.4 2306 - solid-refresh: 0.6.3(solid-js@1.9.4) 2305 + solid-js: 1.9.5 2306 + solid-refresh: 0.6.3(solid-js@1.9.5) 2307 2307 vite: 6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2) 2308 2308 vitefu: 1.0.5(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)) 2309 2309 transitivePeerDependencies:
+221
src/components/backlinks.tsx
··· 1 + import { createSignal, createMemo, onMount, Show, For } from "solid-js"; 2 + import { getRecordBacklinks, getDidBacklinks, LinkData } from "../utils/api.js"; 3 + import * as TID from "@atcute/tid"; 4 + import { localDateFromTimestamp } from "../utils/date.js"; 5 + 6 + // the actual backlink api will probably become closer to this 7 + const linksBySource = (links: Record<string, any>) => { 8 + let out: any[] = []; 9 + Object.keys(links) 10 + .toSorted() 11 + .forEach((collection) => { 12 + const paths = links[collection]; 13 + Object.keys(paths) 14 + .toSorted() 15 + .forEach((path) => { 16 + if (paths[path].records === 0) return; 17 + out.push({ collection, path, counts: paths[path] }); 18 + }); 19 + }); 20 + return { links: out }; 21 + }; 22 + 23 + const Backlinks = ({ links, target }: { links: LinkData; target: string }) => { 24 + const [show, setShow] = createSignal<{ 25 + collection: string; 26 + path: string; 27 + showDids: boolean; 28 + } | null>(); 29 + 30 + const filteredLinks = createMemo(() => linksBySource(links)); 31 + 32 + return ( 33 + <div class="flex flex-col pb-2"> 34 + <p class="font-sans font-semibold text-stone-600 dark:text-stone-400"> 35 + Backlinks{" "} 36 + <a 37 + href="https://links.bsky.bad-example.com" 38 + title="constellation: atproto backlink index" 39 + target="_blank" 40 + > 41 + 🌌 42 + </a>{" "} 43 + </p> 44 + <For each={filteredLinks().links}> 45 + {({ collection, path, matchesFilter, counts }) => ( 46 + <div class="mt-2 font-mono text-sm sm:text-base"> 47 + <p classList={{ "text-stone-400": matchesFilter }}> 48 + <span title="Collection containing linking records"> 49 + {collection} 50 + </span> 51 + <span class="text-cyan-500">@</span> 52 + <span title="Record path where the link is found"> 53 + {path.slice(1)} 54 + </span> 55 + : 56 + </p> 57 + <div class="pl-2.5 font-sans"> 58 + <p> 59 + <a 60 + class="text-lightblue-500 font-sans hover:underline" 61 + href="#" 62 + title="Show linking records" 63 + onclick={() => 64 + ( 65 + show()?.collection === collection && 66 + show()?.path === path && 67 + !show()?.showDids 68 + ) ? 69 + setShow(null) 70 + : setShow({ collection, path, showDids: false }) 71 + } 72 + > 73 + {counts.records} record{counts.records < 2 ? "" : "s"} 74 + </a> 75 + {" from "} 76 + <a 77 + class="text-lightblue-500 font-sans hover:underline" 78 + href="#" 79 + title="Show linking DIDs" 80 + onclick={() => 81 + ( 82 + show()?.collection === collection && 83 + show()?.path === path && 84 + show()?.showDids 85 + ) ? 86 + setShow(null) 87 + : setShow({ collection, path, showDids: true }) 88 + } 89 + > 90 + {counts.distinct_dids} DID 91 + {counts.distinct_dids < 2 ? "" : "s"} 92 + </a> 93 + </p> 94 + <Show 95 + when={ 96 + show()?.collection === collection && show()?.path === path 97 + } 98 + > 99 + <Show when={show()?.showDids}> 100 + {/* putting this in the `dids` prop directly failed to re-render. idk how to solidjs. */} 101 + <p class="w-full font-semibold text-stone-600 dark:text-stone-400"> 102 + Distinct identities 103 + </p> 104 + <BacklinkItems 105 + target={target} 106 + collection={collection} 107 + path={path} 108 + dids={true} 109 + /> 110 + </Show> 111 + <Show when={!show()?.showDids}> 112 + <p class="w-full font-semibold text-stone-600 dark:text-stone-400"> 113 + Records 114 + </p> 115 + <BacklinkItems 116 + target={target} 117 + collection={collection} 118 + path={path} 119 + dids={false} 120 + /> 121 + </Show> 122 + </Show> 123 + </div> 124 + </div> 125 + )} 126 + </For> 127 + </div> 128 + ); 129 + }; 130 + 131 + // switching on !!did everywhere is pretty annoying, this could probably be two components 132 + // but i don't want to duplicate or think about how to extract the paging logic 133 + const BacklinkItems = ({ 134 + target, 135 + collection, 136 + path, 137 + dids, 138 + cursor, 139 + }: { 140 + target: string; 141 + collection: string; 142 + path: string; 143 + dids: boolean; 144 + cursor?: string; 145 + }) => { 146 + const [links, setLinks] = createSignal<any>(); 147 + const [more, setMore] = createSignal<boolean>(false); 148 + 149 + onMount(async () => { 150 + const links = await (dids ? getDidBacklinks : getRecordBacklinks)( 151 + target, 152 + collection, 153 + path, 154 + cursor, 155 + ); 156 + setLinks(links); 157 + }); 158 + 159 + // TODO: could pass the `total` into this component, which can be checked against each call to this endpoint to find if it's stale. 160 + // also hmm 'total' is misleading/wrong on that api 161 + 162 + return ( 163 + <Show when={links()} fallback={<p>Loading&hellip;</p>}> 164 + <Show when={dids}> 165 + <For each={links().linking_dids}> 166 + {(did) => ( 167 + <a 168 + href={`/at://${did}`} 169 + class="text-lightblue-500 relative flex w-full font-mono hover:underline" 170 + > 171 + {did} 172 + </a> 173 + )} 174 + </For> 175 + </Show> 176 + <Show when={!dids}> 177 + <For each={links().linking_records}> 178 + {({ did, collection, rkey }) => ( 179 + <p class="relative flex w-full items-center gap-1 font-mono"> 180 + <a 181 + href={`/at://${did}/${collection}/${rkey}`} 182 + class="text-lightblue-500 hover:underline" 183 + > 184 + {rkey} 185 + </a> 186 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 187 + {TID.validate(rkey) ? 188 + localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) 189 + : undefined} 190 + </span> 191 + </p> 192 + )} 193 + </For> 194 + </Show> 195 + <Show when={links().cursor}> 196 + <Show 197 + when={more()} 198 + fallback={ 199 + <button 200 + type="button" 201 + onclick={() => setMore(true)} 202 + class="dark:bg-dark-700 dark:hover:bg-dark-800 rounded-lg border border-gray-400 bg-white px-2 py-1.5 text-sm font-bold hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-gray-300" 203 + > 204 + Load More 205 + </button> 206 + } 207 + > 208 + <BacklinkItems 209 + target={target} 210 + collection={collection} 211 + path={path} 212 + dids={dids} 213 + cursor={links().cursor} 214 + /> 215 + </Show> 216 + </Show> 217 + </Show> 218 + ); 219 + }; 220 + 221 + export { Backlinks };
+2 -2
src/components/create.tsx
··· 3 3 import { agent } from "../components/login.jsx"; 4 4 import { Editor } from "../components/editor.jsx"; 5 5 import { editor } from "monaco-editor"; 6 - import { theme } from "../layout.jsx"; 6 + import { theme } from "../components/settings.jsx"; 7 7 import { action, redirect } from "@solidjs/router"; 8 8 import { ComAtprotoRepoCreateRecord } from "@atcute/client/lexicons"; 9 9 import Tooltip from "./tooltip.jsx"; ··· 138 138 </select> 139 139 </div> 140 140 </div> 141 - <Editor theme={theme()} model={model!} /> 141 + <Editor theme={theme().color} model={model!} /> 142 142 <div class="flex flex-col gap-x-2"> 143 143 <div class="text-red-500 dark:text-red-400"> 144 144 {createNotice()}
+177
src/components/settings.tsx
··· 1 + import { createSignal, onMount, Show, onCleanup, createEffect } from "solid-js"; 2 + import Tooltip from "./tooltip.jsx"; 3 + 4 + const getInitialTheme = () => { 5 + const isDarkMode = 6 + localStorage.theme === "dark" || 7 + (!("theme" in localStorage) && 8 + window.matchMedia("(prefers-color-scheme: dark)").matches); 9 + return { 10 + color: isDarkMode ? "dark" : "light", 11 + system: !("theme" in localStorage), 12 + }; 13 + }; 14 + 15 + export const [theme, setTheme] = createSignal(getInitialTheme()); 16 + const [backlinksEnabled, setBacklinksEnabled] = createSignal( 17 + localStorage.backlinks === "true", 18 + ); 19 + 20 + const Settings = () => { 21 + const [modal, setModal] = createSignal<HTMLDialogElement>(); 22 + const [openSettings, setOpenSettings] = createSignal(false); 23 + 24 + const clickEvent = (event: MouseEvent) => { 25 + if (modal() && event.target == modal()) setOpenSettings(false); 26 + }; 27 + const keyEvent = (event: KeyboardEvent) => { 28 + if (modal() && event.key == "Escape") setOpenSettings(false); 29 + }; 30 + 31 + onMount(() => { 32 + window.addEventListener("keydown", keyEvent); 33 + window.addEventListener("click", clickEvent); 34 + }); 35 + 36 + onCleanup(() => { 37 + window.removeEventListener("keydown", keyEvent); 38 + window.removeEventListener("click", clickEvent); 39 + }); 40 + 41 + createEffect(() => { 42 + if (openSettings()) document.body.style.overflow = "hidden"; 43 + else document.body.style.overflow = "auto"; 44 + }); 45 + 46 + const updateTheme = (newTheme: { color: string; system: boolean }) => { 47 + setTheme(newTheme); 48 + document.documentElement.classList.toggle( 49 + "dark", 50 + newTheme.color === "dark", 51 + ); 52 + if (newTheme.system) { 53 + localStorage.removeItem("theme"); 54 + } else { 55 + localStorage.theme = newTheme.color; 56 + } 57 + }; 58 + 59 + return ( 60 + <> 61 + <Show when={openSettings()}> 62 + <dialog 63 + ref={setModal} 64 + class="backdrop-brightness-60 fixed left-0 top-0 z-20 flex h-screen w-screen items-center justify-center bg-transparent" 65 + > 66 + <div class="dark:bg-dark-400 top-10% absolute rounded-md border border-slate-900 bg-slate-100 p-4 text-slate-900 dark:border-slate-100 dark:text-slate-100"> 67 + <h3 class="mb-2 border-b border-neutral-500 pb-2 text-xl font-bold"> 68 + Settings 69 + </h3> 70 + <h4 class="mb-1 font-semibold">Theme</h4> 71 + <div class="w-xs flex divide-x divide-neutral-500 overflow-hidden rounded-lg border border-neutral-500"> 72 + <button 73 + classList={{ 74 + "basis-1/3 p-2": true, 75 + "bg-transparent hover:bg-slate-200 dark:hover:bg-dark-200": 76 + !theme().system, 77 + "bg-neutral-500 text-slate-100": theme().system, 78 + }} 79 + onclick={() => 80 + updateTheme({ 81 + color: 82 + ( 83 + window.matchMedia("(prefers-color-scheme: dark)") 84 + .matches 85 + ) ? 86 + "dark" 87 + : "light", 88 + system: true, 89 + }) 90 + } 91 + > 92 + System 93 + </button> 94 + <button 95 + classList={{ 96 + "basis-1/3 p-2": true, 97 + "bg-transparent hover:bg-slate-200 dark:hover:bg-dark-200": 98 + theme().color !== "light" || theme().system, 99 + "bg-neutral-500 text-slate-100": 100 + theme().color === "light" && !theme().system, 101 + }} 102 + onclick={() => updateTheme({ color: "light", system: false })} 103 + > 104 + Light 105 + </button> 106 + <button 107 + classList={{ 108 + "basis-1/3 p-2": true, 109 + "bg-transparent hover:bg-slate-200 dark:hover:bg-dark-200": 110 + theme().color !== "dark" || theme().system, 111 + "bg-neutral-500": theme().color === "dark" && !theme().system, 112 + }} 113 + onclick={() => updateTheme({ color: "dark", system: false })} 114 + > 115 + Dark 116 + </button> 117 + </div> 118 + <div class="mt-4 flex flex-col gap-1 border-t border-neutral-500 pt-2"> 119 + <div class="flex items-center gap-1"> 120 + <input 121 + id="backlinks" 122 + class="size-4" 123 + type="checkbox" 124 + checked={localStorage.backlinks === "true"} 125 + onChange={(e) => { 126 + localStorage.backlinks = e.currentTarget.checked; 127 + setBacklinksEnabled(e.currentTarget.checked); 128 + }} 129 + /> 130 + <label for="backlinks" class="select-none font-semibold"> 131 + Backlinks 132 + </label> 133 + </div> 134 + <div class="flex flex-col gap-1"> 135 + <label 136 + for="constellation" 137 + classList={{ 138 + "select-none": true, 139 + "text-gray-500": !backlinksEnabled(), 140 + }} 141 + > 142 + Constellation host 143 + </label> 144 + <input 145 + id="constellation" 146 + name="constellation" 147 + type="text" 148 + spellcheck={false} 149 + value={ 150 + localStorage.constellationHost || 151 + "https://links.bsky.bad-example.com" 152 + } 153 + disabled={!backlinksEnabled()} 154 + class="dark:bg-dark-100 rounded-lg border border-gray-400 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-gray-300 disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500 dark:disabled:border-gray-700 dark:disabled:bg-gray-800/20" 155 + onInput={(e) => 156 + (localStorage.constellationHost = e.currentTarget.value) 157 + } 158 + /> 159 + </div> 160 + </div> 161 + </div> 162 + </dialog> 163 + </Show> 164 + <Tooltip 165 + text="Settings" 166 + children={ 167 + <button 168 + class="i-majesticons-settings-cog cursor-pointer text-xl" 169 + onclick={() => setOpenSettings(true)} 170 + /> 171 + } 172 + /> 173 + </> 174 + ); 175 + }; 176 + 177 + export { Settings };
+1 -1
src/components/tooltip.tsx
··· 10 10 <Show when={!isTouchDevice}> 11 11 <span 12 12 style={`transform: translate(-50%, 2rem)`} 13 - class={`left-50% pointer-events-none absolute z-10 hidden whitespace-nowrap text-slate-900 dark:bg-neutral-800 dark:text-slate-100 min-w-[${width.toString()}ch] rounded border border-neutral-500 bg-white p-1 text-center text-xs group-hover/tooltip:inline`} 13 + class={`left-50% pointer-events-none absolute z-10 hidden select-none whitespace-nowrap text-slate-900 dark:bg-neutral-800 dark:text-slate-100 min-w-[${width.toString()}ch] rounded border border-neutral-500 bg-white p-1 text-center text-xs group-hover/tooltip:inline`} 14 14 > 15 15 {props.text} 16 16 </span>
+3 -27
src/layout.tsx
··· 1 - import { createSignal, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 1 + import { ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 2 2 import { A, RouteSectionProps, useLocation, useParams } from "@solidjs/router"; 3 3 import { agent, loginState, retrieveSession } from "./components/login.jsx"; 4 4 import { CreateRecord } from "./components/create.jsx"; ··· 8 8 import { AccountManager } from "./components/account.jsx"; 9 9 import { resolveHandle } from "./utils/api.js"; 10 10 import { Meta, MetaProvider } from "@solidjs/meta"; 11 - 12 - export const [theme, setTheme] = createSignal( 13 - ( 14 - localStorage.theme === "dark" || 15 - (!("theme" in localStorage) && 16 - globalThis.matchMedia("(prefers-color-scheme: dark)").matches) 17 - ) ? 18 - "dark" 19 - : "light", 20 - ); 11 + import { Settings } from "./components/settings.jsx"; 21 12 22 13 const Layout = (props: RouteSectionProps<unknown>) => { 23 14 try { ··· 74 65 <div class="i-bi-github text-xl" /> 75 66 </Tooltip> 76 67 </a> 77 - <div 78 - class="w-fit cursor-pointer" 79 - onclick={() => { 80 - setTheme(theme() === "light" ? "dark" : "light"); 81 - if (theme() === "dark") 82 - document.documentElement.classList.add("dark"); 83 - else document.documentElement.classList.remove("dark"); 84 - localStorage.theme = theme(); 85 - }} 86 - > 87 - <Tooltip text="Theme"> 88 - {theme() === "dark" ? 89 - <div class="i-tabler-moon-stars text-xl" /> 90 - : <div class="i-tabler-sun text-xl" />} 91 - </Tooltip> 92 - </div> 68 + <Settings /> 93 69 </div> 94 70 </div> 95 71 <div class="mb-5 flex max-w-full flex-col items-center text-pretty md:max-w-screen-md">
+12 -24
src/styles/icons.css
··· 10 10 height: 1.2em; 11 11 } 12 12 13 - .i-tabler-moon-stars { 14 - --un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1.2em' height='1.2em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 3h.393a7.5 7.5 0 0 0 7.92 12.446A9 9 0 1 1 12 2.992zm5 1a2 2 0 0 0 2 2a2 2 0 0 0-2 2a2 2 0 0 0-2-2a2 2 0 0 0 2-2m2 7h2m-1-1v2'/%3E%3C/svg%3E"); 15 - -webkit-mask: var(--un-icon) no-repeat; 16 - mask: var(--un-icon) no-repeat; 17 - -webkit-mask-size: 100% 100%; 18 - mask-size: 100% 100%; 19 - background-color: currentColor; 20 - color: inherit; 21 - width: 1.2em; 22 - height: 1.2em; 23 - } 24 - 25 - .i-tabler-sun { 26 - --un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1.2em' height='1.2em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 12a4 4 0 1 0 8 0a4 4 0 1 0-8 0m-5 0h1m8-9v1m8 8h1m-9 8v1M5.6 5.6l.7.7m12.1-.7l-.7.7m0 11.4l.7.7m-12.1-.7l-.7.7'/%3E%3C/svg%3E"); 27 - -webkit-mask: var(--un-icon) no-repeat; 28 - mask: var(--un-icon) no-repeat; 29 - -webkit-mask-size: 100% 100%; 30 - mask-size: 100% 100%; 31 - background-color: currentColor; 32 - color: inherit; 33 - width: 1.2em; 34 - height: 1.2em; 35 - } 36 - 37 13 .i-fluent-checkmark-circle-12-regular { 38 14 --un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 12 12' width='1.2em' height='1.2em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' d='M8.354 5.104a.5.5 0 1 0-.708-.708L5.5 6.543L4.354 5.396a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0zM6 1a5 5 0 1 0 0 10A5 5 0 0 0 6 1M2 6a4 4 0 1 1 8 0a4 4 0 0 1-8 0'/%3E%3C/svg%3E"); 39 15 -webkit-mask: var(--un-icon) no-repeat; ··· 315 291 width: 1.2em; 316 292 height: 1.2em; 317 293 } 294 + 295 + .i-majesticons-settings-cog { 296 + --un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1.2em' height='1.2em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='currentColor' fill-rule='evenodd' d='M9.024 2.783A1 1 0 0 1 10 2h4a1 1 0 0 1 .976.783l.44 1.981q.6.285 1.14.66l1.938-.61a1 1 0 0 1 1.166.454l2 3.464a1 1 0 0 1-.19 1.237l-1.497 1.373a8 8 0 0 1 0 1.316l1.497 1.373a1 1 0 0 1 .19 1.237l-2 3.464a1 1 0 0 1-1.166.454l-1.937-.61q-.54.375-1.14.66l-.44 1.98A1 1 0 0 1 14 22h-4a1 1 0 0 1-.976-.783l-.44-1.981q-.6-.285-1.14-.66l-1.938.61a1 1 0 0 1-1.166-.454l-2-3.464a1 1 0 0 1 .19-1.237l1.497-1.373a8 8 0 0 1 0-1.316L2.53 9.97a1 1 0 0 1-.19-1.237l2-3.464a1 1 0 0 1 1.166-.454l1.937.61q.54-.375 1.14-.66l.44-1.98zM12 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6' clip-rule='evenodd'/%3E%3C/svg%3E"); 297 + -webkit-mask: var(--un-icon) no-repeat; 298 + mask: var(--un-icon) no-repeat; 299 + -webkit-mask-size: 100% 100%; 300 + mask-size: 100% 100%; 301 + background-color: currentColor; 302 + color: inherit; 303 + width: 1.2em; 304 + height: 1.2em; 305 + }
+79 -1
src/utils/api.ts
··· 4 4 import { DidDocument } from "@atcute/client/utils/did"; 5 5 import { createStore } from "solid-js/store"; 6 6 7 + localStorage.constellationHost = 8 + localStorage.constellationHost || "https://links.bsky.bad-example.com"; 9 + 7 10 const didPDSCache: Record<string, string> = {}; 8 11 const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({}); 9 12 const didDocCache: Record<string, DidDocument> = {}; ··· 47 50 return pds; 48 51 }; 49 52 50 - export { getPDS, labelerCache, didDocCache, resolveHandle, resolvePDS }; 53 + interface LinkData { 54 + links: { 55 + [key: string]: { 56 + [key: string]: { 57 + records: number; 58 + distinct_dids: number; 59 + }; 60 + }; 61 + }; 62 + } 63 + 64 + const getConstellation = async ( 65 + endpoint: string, 66 + target: string, 67 + collection?: string, 68 + path?: string, 69 + cursor?: string, 70 + limit?: number, 71 + ) => { 72 + const url = new URL(localStorage.constellationHost); 73 + url.pathname = endpoint; 74 + url.searchParams.set("target", target); 75 + if (collection) { 76 + if (!path) 77 + throw new Error("collection and path must either both be set or neither"); 78 + url.searchParams.set("collection", collection); 79 + url.searchParams.set("path", path); 80 + } else { 81 + if (path) 82 + throw new Error("collection and path must either both be set or neither"); 83 + } 84 + if (limit) url.searchParams.set("limit", `${limit}`); 85 + if (cursor) url.searchParams.set("cursor", `${cursor}`); 86 + const res = await fetch(url); 87 + if (!res.ok) throw new Error("failed to fetch from constellation"); 88 + return await res.json(); 89 + }; 90 + 91 + const getAllBacklinks = (target: string) => 92 + getConstellation("/links/all", target); 93 + 94 + const getRecordBacklinks = ( 95 + target: string, 96 + collection: string, 97 + path: string, 98 + cursor?: string, 99 + limit?: number, 100 + ) => getConstellation("/links", target, collection, path, cursor, limit || 100); 101 + 102 + const getDidBacklinks = ( 103 + target: string, 104 + collection: string, 105 + path: string, 106 + cursor?: string, 107 + limit?: number, 108 + ) => 109 + getConstellation( 110 + "/links/distinct-dids", 111 + target, 112 + collection, 113 + path, 114 + cursor, 115 + limit || 100, 116 + ); 117 + 118 + export { 119 + getPDS, 120 + getAllBacklinks, 121 + getRecordBacklinks, 122 + getDidBacklinks, 123 + labelerCache, 124 + didDocCache, 125 + resolveHandle, 126 + resolvePDS, 127 + type LinkData, 128 + };
+10
src/views/home.tsx
··· 44 44 </A> 45 45 . 46 46 </p> 47 + <p> 48 + <A 49 + href="https://links.bsky.bad-example.com" 50 + class="text-lightblue-500 hover:underline" 51 + target="_blank" 52 + > 53 + Backlinks 54 + </A>{" "} 55 + can be enabled in the settings. 56 + </p> 47 57 </div> 48 58 <p>Examples:</p> 49 59 <div class="ml-2">
+41 -9
src/views/record.tsx
··· 6 6 import { authenticate_post_with_doc } from "public-transport"; 7 7 import { agent, loginState } from "../components/login.jsx"; 8 8 import { Editor } from "../components/editor.jsx"; 9 + import { Backlinks } from "../components/backlinks.jsx"; 9 10 import { editor } from "monaco-editor"; 10 11 import { setCID, setValidRecord, validRecord } from "../components/navbar.jsx"; 11 - import { didDocCache, resolveHandle, resolvePDS } from "../utils/api.js"; 12 - import { theme } from "../layout.jsx"; 12 + import { 13 + didDocCache, 14 + getAllBacklinks, 15 + LinkData, 16 + resolveHandle, 17 + resolvePDS, 18 + } from "../utils/api.js"; 19 + import { theme } from "../components/settings.jsx"; 13 20 import { AtUri, uriTemplates } from "../utils/templates.js"; 14 21 15 22 const RecordView = () => { 16 23 const params = useParams(); 17 24 const [record, setRecord] = createSignal<ComAtprotoRepoGetRecord.Output>(); 25 + const [backlinks, setBacklinks] = createSignal<{ 26 + links: LinkData; 27 + target: string; 28 + }>(); 18 29 const [modal, setModal] = createSignal<HTMLDialogElement>(); 19 30 const [openDelete, setOpenDelete] = createSignal(false); 20 31 const [openEdit, setOpenEdit] = createSignal(false); ··· 62 73 if (err.message) setNotice(err.message); 63 74 else setNotice(`Invalid record: ${err}`); 64 75 setValidRecord(false); 76 + } 77 + if (localStorage.backlinks === "true") { 78 + const backlinkTarget = `at://${did}/${params.collection}/${params.rkey}`; 79 + const backlinks = await getAllBacklinks(backlinkTarget); 80 + setBacklinks({ links: backlinks.links, target: backlinkTarget }); 65 81 } 66 82 }); 67 83 ··· 209 225 <option value="false">False</option> 210 226 </select> 211 227 </div> 212 - <Editor theme={theme()} model={model!} /> 228 + <Editor theme={theme().color} model={model!} /> 213 229 <div class="mt-2 flex flex-col gap-2"> 214 230 <div class="text-red-500 dark:text-red-400"> 215 231 {editNotice()} ··· 290 306 </button> 291 307 </Show> 292 308 </div> 293 - <div class="break-anywhere mt-1 whitespace-pre-wrap pl-3.5 font-mono text-sm sm:text-base"> 294 - <Show when={!JSONSyntax()}> 309 + <Show when={!JSONSyntax()}> 310 + <div 311 + classList={{ 312 + "break-anywhere mb-2 mt-1 whitespace-pre-wrap pb-3 font-mono text-sm sm:text-base": 313 + true, 314 + "border-b border-neutral-500": !!backlinks(), 315 + }} 316 + > 295 317 <JSONValue 296 318 data={record()?.value as any} 297 319 repo={record()!.uri.split("/")[2]} 298 320 /> 321 + </div> 322 + <Show when={backlinks()}> 323 + {(backlinks) => ( 324 + <Backlinks 325 + links={backlinks().links} 326 + target={backlinks().target} 327 + /> 328 + )} 299 329 </Show> 300 - <Show when={JSONSyntax()}> 330 + </Show> 331 + <Show when={JSONSyntax()}> 332 + <div class="mt-1"> 301 333 <Editor 302 - theme={theme()} 334 + theme={theme().color} 303 335 model={editor.createModel( 304 336 JSON.stringify(record()?.value, null, 2).replace( 305 337 /[\u007F-\uFFFF]/g, ··· 310 342 )} 311 343 readOnly={true} 312 344 /> 313 - </Show> 314 - </div> 345 + </div> 346 + </Show> 315 347 </Show> 316 348 </> 317 349 );
+26 -1
src/views/repo.tsx
··· 1 1 import { createSignal, For, Show, createResource } from "solid-js"; 2 2 import { CredentialManager, XRPC } from "@atcute/client"; 3 3 import { A, query, useParams } from "@solidjs/router"; 4 - import { didDocCache, resolveHandle, resolvePDS } from "../utils/api.js"; 4 + import { 5 + didDocCache, 6 + getAllBacklinks, 7 + LinkData, 8 + resolveHandle, 9 + resolvePDS, 10 + } from "../utils/api.js"; 5 11 import { DidDocument } from "@atcute/client/utils/did"; 12 + import { Backlinks } from "../components/backlinks.jsx"; 6 13 7 14 const RepoView = () => { 8 15 const params = useParams(); 9 16 const [didDoc, setDidDoc] = createSignal<DidDocument>(); 17 + const [backlinks, setBacklinks] = createSignal<{ 18 + links: LinkData; 19 + target: string; 20 + }>(); 10 21 let rpc: XRPC; 11 22 let did = params.repo; 12 23 ··· 22 33 rpc = new XRPC({ handler: new CredentialManager({ service: pds }) }); 23 34 const res = await describeRepo(did); 24 35 setDidDoc(didDocCache[did]); 36 + if (localStorage.backlinks === "true") { 37 + const backlinks = await getAllBacklinks(did); 38 + setBacklinks({ links: backlinks.links, target: did }); 39 + } 25 40 return res.data; 26 41 }; 27 42 ··· 121 136 PLC operation logs{" "} 122 137 <div class="i-tabler-external-link ml-0.5 text-xs" /> 123 138 </a> 139 + </Show> 140 + <Show when={backlinks()}> 141 + {(backlinks) => ( 142 + <div class="mt-2 border-t border-neutral-500 pt-2"> 143 + <Backlinks 144 + links={backlinks().links} 145 + target={backlinks().target} 146 + /> 147 + </div> 148 + )} 124 149 </Show> 125 150 </div> 126 151 )}