atproto explorer pdsls.dev
atproto tool

Backlinks support (#21)

resolves #17
opt-in backlinks support and settings page

---------

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

+3 -3
package.json
···
"serve": "vite preview"
},
"devDependencies": {
-
"prettier": "^3.5.1",
+
"prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"typescript": "^5.7.3",
"unocss": "^66.0.0",
···
"dependencies": {
"@atcute/car": "^2.0.3",
"@atcute/cbor": "^2.1.3",
-
"@atcute/client": "^2.0.7",
+
"@atcute/client": "^2.0.8",
"@atcute/oauth-browser-client": "^1.0.13",
"@atcute/tid": "^1.0.2",
"@solidjs/meta": "^0.29.4",
···
"hls.js": "^1.5.20",
"monaco-editor": "^0.52.2",
"public-transport": "file:pkg/pt.tgz",
-
"solid-js": "^1.9.4"
+
"solid-js": "^1.9.5"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}
+43 -43
pnpm-lock.yaml
···
specifier: ^2.1.3
version: 2.1.3
'@atcute/client':
-
specifier: ^2.0.7
-
version: 2.0.7
+
specifier: ^2.0.8
+
version: 2.0.8
'@atcute/oauth-browser-client':
specifier: ^1.0.13
version: 1.0.13
···
version: 1.0.2
'@solidjs/meta':
specifier: ^0.29.4
-
version: 0.29.4(solid-js@1.9.4)
+
version: 0.29.4(solid-js@1.9.5)
'@solidjs/router':
specifier: ^0.15.3
-
version: 0.15.3(solid-js@1.9.4)
+
version: 0.15.3(solid-js@1.9.5)
hls.js:
specifier: ^1.5.20
version: 1.5.20
···
specifier: file:pkg/pt.tgz
version: file:pkg/pt.tgz
solid-js:
-
specifier: ^1.9.4
-
version: 1.9.4
+
specifier: ^1.9.5
+
version: 1.9.5
devDependencies:
prettier:
-
specifier: ^3.5.1
-
version: 3.5.1
+
specifier: ^3.5.2
+
version: 3.5.2
prettier-plugin-tailwindcss:
specifier: ^0.6.11
-
version: 0.6.11(prettier@3.5.1)
+
version: 0.6.11(prettier@3.5.2)
typescript:
specifier: ^5.7.3
version: 5.7.3
···
version: 6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)
vite-plugin-solid:
specifier: ^2.11.2
-
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))
+
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))
vite-plugin-wasm:
specifier: ^3.4.1
version: 3.4.1(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2))
···
'@atcute/cid@2.1.0':
resolution: {integrity: sha512-Twsf5OKGk6QEqU1Z74feo1eRmnznYFzdCxtCISCutedCL2I2eGzD1F7JZRA+heTp5kgV5Bwvxvjn7VkGQhq3Sg==}
-
'@atcute/client@2.0.7':
-
resolution: {integrity: sha512-bvNahrCGvhZw/EIx0HU/GOoKZEnUaAppbuZh7cu+VsOFA2tdFLnZJed9Hagh5Yz/eUX7QUh5NB4dRTRUdggSLQ==}
+
'@atcute/client@2.0.8':
+
resolution: {integrity: sha512-OTfiWwjB4mOTlp2InGStvoQ+PIA5lvih9cTYU8BvOhzNcCBUpt4l860MKZExHjvQ9Tt1kjq/ED9zRiUjsAgIxw==}
'@atcute/multibase@1.1.2':
resolution: {integrity: sha512-KFX+c7a/u2jSNcRw0rLaUHG/XEKf1A1c8XF5soHnsb1JMCShihf/anfZ1kJ4no/IlIp9HEHV3PQRQO2sWL6ASQ==}
···
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
-
babel-plugin-jsx-dom-expressions@0.39.6:
-
resolution: {integrity: sha512-HMkTn5A3NyydEgG7HKmm48YcnsQQyqeT6SKNWh2TrS6nn5rOLeHDfg5hPbrRUCFUqaT9WGn5NInQfMc3qne3Dg==}
+
babel-plugin-jsx-dom-expressions@0.39.7:
+
resolution: {integrity: sha512-8GzVmFla7jaTNWW8W+lTMl9YGva4/06CtwJjySnkYtt8G1v9weCzc2SuF1DfrudcCNb2Doetc1FRg33swBYZCA==}
peerDependencies:
'@babel/core': ^7.20.12
-
babel-preset-solid@1.9.3:
-
resolution: {integrity: sha512-jvlx5wDp8s+bEF9sGFw/84SInXOA51ttkUEroQziKMbxplXThVKt83qB6bDTa1HuLNatdU9FHpFOiQWs1tLQIg==}
+
babel-preset-solid@1.9.5:
+
resolution: {integrity: sha512-85I3osODJ1LvZbv8wFozROV1vXq32BubqHXAGu73A//TRs3NLI1OFP83AQBUTSQHwgZQmARjHlJciym3we+V+w==}
peerDependencies:
'@babel/core': ^7.0.0
···
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
-
electron-to-chromium@1.5.102:
-
resolution: {integrity: sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==}
+
electron-to-chromium@1.5.103:
+
resolution: {integrity: sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
···
prettier-plugin-svelte:
optional: true
-
prettier@3.5.1:
-
resolution: {integrity: sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==}
+
prettier@3.5.2:
+
resolution: {integrity: sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==}
engines: {node: '>=14'}
hasBin: true
···
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
engines: {node: '>=18'}
-
solid-js@1.9.4:
-
resolution: {integrity: sha512-ipQl8FJ31bFUoBNScDQTG3BjN6+9Rg+Q+f10bUbnO6EOTTf5NGerJeHc7wyu5I4RMHEl/WwZwUmy/PTRgxxZ8g==}
+
solid-js@1.9.5:
+
resolution: {integrity: sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==}
solid-refresh@0.6.3:
resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
···
'@atcute/uint8array': 1.0.1
'@atcute/varint': 1.0.2
-
'@atcute/client@2.0.7': {}
+
'@atcute/client@2.0.8': {}
'@atcute/multibase@1.1.2':
dependencies:
···
'@atcute/oauth-browser-client@1.0.13':
dependencies:
-
'@atcute/client': 2.0.7
+
'@atcute/client': 2.0.8
'@atcute/tid@1.0.2': {}
···
'@rollup/rollup-win32-x64-msvc@4.34.8':
optional: true
-
'@solidjs/meta@0.29.4(solid-js@1.9.4)':
+
'@solidjs/meta@0.29.4(solid-js@1.9.5)':
dependencies:
-
solid-js: 1.9.4
+
solid-js: 1.9.5
-
'@solidjs/router@0.15.3(solid-js@1.9.4)':
+
'@solidjs/router@0.15.3(solid-js@1.9.5)':
dependencies:
-
solid-js: 1.9.4
+
solid-js: 1.9.5
'@types/babel__core@7.20.5':
dependencies:
···
normalize-path: 3.0.0
picomatch: 2.3.1
-
babel-plugin-jsx-dom-expressions@0.39.6(@babel/core@7.26.9):
+
babel-plugin-jsx-dom-expressions@0.39.7(@babel/core@7.26.9):
dependencies:
'@babel/core': 7.26.9
'@babel/helper-module-imports': 7.18.6
···
parse5: 7.2.1
validate-html-nesting: 1.2.2
-
babel-preset-solid@1.9.3(@babel/core@7.26.9):
+
babel-preset-solid@1.9.5(@babel/core@7.26.9):
dependencies:
'@babel/core': 7.26.9
-
babel-plugin-jsx-dom-expressions: 0.39.6(@babel/core@7.26.9)
+
babel-plugin-jsx-dom-expressions: 0.39.7(@babel/core@7.26.9)
binary-extensions@2.3.0: {}
···
browserslist@4.24.4:
dependencies:
caniuse-lite: 1.0.30001700
-
electron-to-chromium: 1.5.102
+
electron-to-chromium: 1.5.103
node-releases: 2.0.19
update-browserslist-db: 1.1.2(browserslist@4.24.4)
···
duplexer@0.1.2: {}
-
electron-to-chromium@1.5.102: {}
+
electron-to-chromium@1.5.103: {}
entities@4.5.0: {}
···
picocolors: 1.1.1
source-map-js: 1.2.1
-
prettier-plugin-tailwindcss@0.6.11(prettier@3.5.1):
+
prettier-plugin-tailwindcss@0.6.11(prettier@3.5.2):
dependencies:
-
prettier: 3.5.1
+
prettier: 3.5.2
-
prettier@3.5.1: {}
+
prettier@3.5.2: {}
public-transport@file:pkg/pt.tgz: {}
···
mrmime: 2.0.1
totalist: 3.0.1
-
solid-js@1.9.4:
+
solid-js@1.9.5:
dependencies:
csstype: 3.1.3
seroval: 1.2.1
seroval-plugins: 1.2.1(seroval@1.2.1)
-
solid-refresh@0.6.3(solid-js@1.9.4):
+
solid-refresh@0.6.3(solid-js@1.9.5):
dependencies:
'@babel/generator': 7.26.9
'@babel/helper-module-imports': 7.25.9
'@babel/types': 7.26.9
-
solid-js: 1.9.4
+
solid-js: 1.9.5
transitivePeerDependencies:
- supports-color
···
validate-html-nesting@1.2.2: {}
-
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)):
+
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)):
dependencies:
'@babel/core': 7.26.9
'@types/babel__core': 7.20.5
-
babel-preset-solid: 1.9.3(@babel/core@7.26.9)
+
babel-preset-solid: 1.9.5(@babel/core@7.26.9)
merge-anything: 5.1.7
-
solid-js: 1.9.4
-
solid-refresh: 0.6.3(solid-js@1.9.4)
+
solid-js: 1.9.5
+
solid-refresh: 0.6.3(solid-js@1.9.5)
vite: 6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2)
vitefu: 1.0.5(vite@6.1.1(@types/node@22.13.1)(jiti@2.4.2)(tsx@4.19.2))
transitivePeerDependencies:
+221
src/components/backlinks.tsx
···
+
import { createSignal, createMemo, onMount, Show, For } from "solid-js";
+
import { getRecordBacklinks, getDidBacklinks, LinkData } from "../utils/api.js";
+
import * as TID from "@atcute/tid";
+
import { localDateFromTimestamp } from "../utils/date.js";
+
+
// the actual backlink api will probably become closer to this
+
const linksBySource = (links: Record<string, any>) => {
+
let out: any[] = [];
+
Object.keys(links)
+
.toSorted()
+
.forEach((collection) => {
+
const paths = links[collection];
+
Object.keys(paths)
+
.toSorted()
+
.forEach((path) => {
+
if (paths[path].records === 0) return;
+
out.push({ collection, path, counts: paths[path] });
+
});
+
});
+
return { links: out };
+
};
+
+
const Backlinks = ({ links, target }: { links: LinkData; target: string }) => {
+
const [show, setShow] = createSignal<{
+
collection: string;
+
path: string;
+
showDids: boolean;
+
} | null>();
+
+
const filteredLinks = createMemo(() => linksBySource(links));
+
+
return (
+
<div class="flex flex-col pb-2">
+
<p class="font-sans font-semibold text-stone-600 dark:text-stone-400">
+
Backlinks{" "}
+
<a
+
href="https://links.bsky.bad-example.com"
+
title="constellation: atproto backlink index"
+
target="_blank"
+
>
+
🌌
+
</a>{" "}
+
</p>
+
<For each={filteredLinks().links}>
+
{({ collection, path, matchesFilter, counts }) => (
+
<div class="mt-2 font-mono text-sm sm:text-base">
+
<p classList={{ "text-stone-400": matchesFilter }}>
+
<span title="Collection containing linking records">
+
{collection}
+
</span>
+
<span class="text-cyan-500">@</span>
+
<span title="Record path where the link is found">
+
{path.slice(1)}
+
</span>
+
:
+
</p>
+
<div class="pl-2.5 font-sans">
+
<p>
+
<a
+
class="text-lightblue-500 font-sans hover:underline"
+
href="#"
+
title="Show linking records"
+
onclick={() =>
+
(
+
show()?.collection === collection &&
+
show()?.path === path &&
+
!show()?.showDids
+
) ?
+
setShow(null)
+
: setShow({ collection, path, showDids: false })
+
}
+
>
+
{counts.records} record{counts.records < 2 ? "" : "s"}
+
</a>
+
{" from "}
+
<a
+
class="text-lightblue-500 font-sans hover:underline"
+
href="#"
+
title="Show linking DIDs"
+
onclick={() =>
+
(
+
show()?.collection === collection &&
+
show()?.path === path &&
+
show()?.showDids
+
) ?
+
setShow(null)
+
: setShow({ collection, path, showDids: true })
+
}
+
>
+
{counts.distinct_dids} DID
+
{counts.distinct_dids < 2 ? "" : "s"}
+
</a>
+
</p>
+
<Show
+
when={
+
show()?.collection === collection && show()?.path === path
+
}
+
>
+
<Show when={show()?.showDids}>
+
{/* putting this in the `dids` prop directly failed to re-render. idk how to solidjs. */}
+
<p class="w-full font-semibold text-stone-600 dark:text-stone-400">
+
Distinct identities
+
</p>
+
<BacklinkItems
+
target={target}
+
collection={collection}
+
path={path}
+
dids={true}
+
/>
+
</Show>
+
<Show when={!show()?.showDids}>
+
<p class="w-full font-semibold text-stone-600 dark:text-stone-400">
+
Records
+
</p>
+
<BacklinkItems
+
target={target}
+
collection={collection}
+
path={path}
+
dids={false}
+
/>
+
</Show>
+
</Show>
+
</div>
+
</div>
+
)}
+
</For>
+
</div>
+
);
+
};
+
+
// switching on !!did everywhere is pretty annoying, this could probably be two components
+
// but i don't want to duplicate or think about how to extract the paging logic
+
const BacklinkItems = ({
+
target,
+
collection,
+
path,
+
dids,
+
cursor,
+
}: {
+
target: string;
+
collection: string;
+
path: string;
+
dids: boolean;
+
cursor?: string;
+
}) => {
+
const [links, setLinks] = createSignal<any>();
+
const [more, setMore] = createSignal<boolean>(false);
+
+
onMount(async () => {
+
const links = await (dids ? getDidBacklinks : getRecordBacklinks)(
+
target,
+
collection,
+
path,
+
cursor,
+
);
+
setLinks(links);
+
});
+
+
// TODO: could pass the `total` into this component, which can be checked against each call to this endpoint to find if it's stale.
+
// also hmm 'total' is misleading/wrong on that api
+
+
return (
+
<Show when={links()} fallback={<p>Loading&hellip;</p>}>
+
<Show when={dids}>
+
<For each={links().linking_dids}>
+
{(did) => (
+
<a
+
href={`/at://${did}`}
+
class="text-lightblue-500 relative flex w-full font-mono hover:underline"
+
>
+
{did}
+
</a>
+
)}
+
</For>
+
</Show>
+
<Show when={!dids}>
+
<For each={links().linking_records}>
+
{({ did, collection, rkey }) => (
+
<p class="relative flex w-full items-center gap-1 font-mono">
+
<a
+
href={`/at://${did}/${collection}/${rkey}`}
+
class="text-lightblue-500 hover:underline"
+
>
+
{rkey}
+
</a>
+
<span class="text-xs text-neutral-500 dark:text-neutral-400">
+
{TID.validate(rkey) ?
+
localDateFromTimestamp(TID.parse(rkey).timestamp / 1000)
+
: undefined}
+
</span>
+
</p>
+
)}
+
</For>
+
</Show>
+
<Show when={links().cursor}>
+
<Show
+
when={more()}
+
fallback={
+
<button
+
type="button"
+
onclick={() => setMore(true)}
+
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"
+
>
+
Load More
+
</button>
+
}
+
>
+
<BacklinkItems
+
target={target}
+
collection={collection}
+
path={path}
+
dids={dids}
+
cursor={links().cursor}
+
/>
+
</Show>
+
</Show>
+
</Show>
+
);
+
};
+
+
export { Backlinks };
+2 -2
src/components/create.tsx
···
import { agent } from "../components/login.jsx";
import { Editor } from "../components/editor.jsx";
import { editor } from "monaco-editor";
-
import { theme } from "../layout.jsx";
+
import { theme } from "../components/settings.jsx";
import { action, redirect } from "@solidjs/router";
import { ComAtprotoRepoCreateRecord } from "@atcute/client/lexicons";
import Tooltip from "./tooltip.jsx";
···
</select>
</div>
</div>
-
<Editor theme={theme()} model={model!} />
+
<Editor theme={theme().color} model={model!} />
<div class="flex flex-col gap-x-2">
<div class="text-red-500 dark:text-red-400">
{createNotice()}
+177
src/components/settings.tsx
···
+
import { createSignal, onMount, Show, onCleanup, createEffect } from "solid-js";
+
import Tooltip from "./tooltip.jsx";
+
+
const getInitialTheme = () => {
+
const isDarkMode =
+
localStorage.theme === "dark" ||
+
(!("theme" in localStorage) &&
+
window.matchMedia("(prefers-color-scheme: dark)").matches);
+
return {
+
color: isDarkMode ? "dark" : "light",
+
system: !("theme" in localStorage),
+
};
+
};
+
+
export const [theme, setTheme] = createSignal(getInitialTheme());
+
const [backlinksEnabled, setBacklinksEnabled] = createSignal(
+
localStorage.backlinks === "true",
+
);
+
+
const Settings = () => {
+
const [modal, setModal] = createSignal<HTMLDialogElement>();
+
const [openSettings, setOpenSettings] = createSignal(false);
+
+
const clickEvent = (event: MouseEvent) => {
+
if (modal() && event.target == modal()) setOpenSettings(false);
+
};
+
const keyEvent = (event: KeyboardEvent) => {
+
if (modal() && event.key == "Escape") setOpenSettings(false);
+
};
+
+
onMount(() => {
+
window.addEventListener("keydown", keyEvent);
+
window.addEventListener("click", clickEvent);
+
});
+
+
onCleanup(() => {
+
window.removeEventListener("keydown", keyEvent);
+
window.removeEventListener("click", clickEvent);
+
});
+
+
createEffect(() => {
+
if (openSettings()) document.body.style.overflow = "hidden";
+
else document.body.style.overflow = "auto";
+
});
+
+
const updateTheme = (newTheme: { color: string; system: boolean }) => {
+
setTheme(newTheme);
+
document.documentElement.classList.toggle(
+
"dark",
+
newTheme.color === "dark",
+
);
+
if (newTheme.system) {
+
localStorage.removeItem("theme");
+
} else {
+
localStorage.theme = newTheme.color;
+
}
+
};
+
+
return (
+
<>
+
<Show when={openSettings()}>
+
<dialog
+
ref={setModal}
+
class="backdrop-brightness-60 fixed left-0 top-0 z-20 flex h-screen w-screen items-center justify-center bg-transparent"
+
>
+
<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">
+
<h3 class="mb-2 border-b border-neutral-500 pb-2 text-xl font-bold">
+
Settings
+
</h3>
+
<h4 class="mb-1 font-semibold">Theme</h4>
+
<div class="w-xs flex divide-x divide-neutral-500 overflow-hidden rounded-lg border border-neutral-500">
+
<button
+
classList={{
+
"basis-1/3 p-2": true,
+
"bg-transparent hover:bg-slate-200 dark:hover:bg-dark-200":
+
!theme().system,
+
"bg-neutral-500 text-slate-100": theme().system,
+
}}
+
onclick={() =>
+
updateTheme({
+
color:
+
(
+
window.matchMedia("(prefers-color-scheme: dark)")
+
.matches
+
) ?
+
"dark"
+
: "light",
+
system: true,
+
})
+
}
+
>
+
System
+
</button>
+
<button
+
classList={{
+
"basis-1/3 p-2": true,
+
"bg-transparent hover:bg-slate-200 dark:hover:bg-dark-200":
+
theme().color !== "light" || theme().system,
+
"bg-neutral-500 text-slate-100":
+
theme().color === "light" && !theme().system,
+
}}
+
onclick={() => updateTheme({ color: "light", system: false })}
+
>
+
Light
+
</button>
+
<button
+
classList={{
+
"basis-1/3 p-2": true,
+
"bg-transparent hover:bg-slate-200 dark:hover:bg-dark-200":
+
theme().color !== "dark" || theme().system,
+
"bg-neutral-500": theme().color === "dark" && !theme().system,
+
}}
+
onclick={() => updateTheme({ color: "dark", system: false })}
+
>
+
Dark
+
</button>
+
</div>
+
<div class="mt-4 flex flex-col gap-1 border-t border-neutral-500 pt-2">
+
<div class="flex items-center gap-1">
+
<input
+
id="backlinks"
+
class="size-4"
+
type="checkbox"
+
checked={localStorage.backlinks === "true"}
+
onChange={(e) => {
+
localStorage.backlinks = e.currentTarget.checked;
+
setBacklinksEnabled(e.currentTarget.checked);
+
}}
+
/>
+
<label for="backlinks" class="select-none font-semibold">
+
Backlinks
+
</label>
+
</div>
+
<div class="flex flex-col gap-1">
+
<label
+
for="constellation"
+
classList={{
+
"select-none": true,
+
"text-gray-500": !backlinksEnabled(),
+
}}
+
>
+
Constellation host
+
</label>
+
<input
+
id="constellation"
+
name="constellation"
+
type="text"
+
spellcheck={false}
+
value={
+
localStorage.constellationHost ||
+
"https://links.bsky.bad-example.com"
+
}
+
disabled={!backlinksEnabled()}
+
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"
+
onInput={(e) =>
+
(localStorage.constellationHost = e.currentTarget.value)
+
}
+
/>
+
</div>
+
</div>
+
</div>
+
</dialog>
+
</Show>
+
<Tooltip
+
text="Settings"
+
children={
+
<button
+
class="i-majesticons-settings-cog cursor-pointer text-xl"
+
onclick={() => setOpenSettings(true)}
+
/>
+
}
+
/>
+
</>
+
);
+
};
+
+
export { Settings };
+1 -1
src/components/tooltip.tsx
···
<Show when={!isTouchDevice}>
<span
style={`transform: translate(-50%, 2rem)`}
-
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`}
+
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`}
>
{props.text}
</span>
+3 -27
src/layout.tsx
···
-
import { createSignal, ErrorBoundary, onMount, Show, Suspense } from "solid-js";
+
import { ErrorBoundary, onMount, Show, Suspense } from "solid-js";
import { A, RouteSectionProps, useLocation, useParams } from "@solidjs/router";
import { agent, loginState, retrieveSession } from "./components/login.jsx";
import { CreateRecord } from "./components/create.jsx";
···
import { AccountManager } from "./components/account.jsx";
import { resolveHandle } from "./utils/api.js";
import { Meta, MetaProvider } from "@solidjs/meta";
-
-
export const [theme, setTheme] = createSignal(
-
(
-
localStorage.theme === "dark" ||
-
(!("theme" in localStorage) &&
-
globalThis.matchMedia("(prefers-color-scheme: dark)").matches)
-
) ?
-
"dark"
-
: "light",
-
);
+
import { Settings } from "./components/settings.jsx";
const Layout = (props: RouteSectionProps<unknown>) => {
try {
···
<div class="i-bi-github text-xl" />
</Tooltip>
</a>
-
<div
-
class="w-fit cursor-pointer"
-
onclick={() => {
-
setTheme(theme() === "light" ? "dark" : "light");
-
if (theme() === "dark")
-
document.documentElement.classList.add("dark");
-
else document.documentElement.classList.remove("dark");
-
localStorage.theme = theme();
-
}}
-
>
-
<Tooltip text="Theme">
-
{theme() === "dark" ?
-
<div class="i-tabler-moon-stars text-xl" />
-
: <div class="i-tabler-sun text-xl" />}
-
</Tooltip>
-
</div>
+
<Settings />
</div>
</div>
<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
···
height: 1.2em;
}
-
.i-tabler-moon-stars {
-
--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");
-
-webkit-mask: var(--un-icon) no-repeat;
-
mask: var(--un-icon) no-repeat;
-
-webkit-mask-size: 100% 100%;
-
mask-size: 100% 100%;
-
background-color: currentColor;
-
color: inherit;
-
width: 1.2em;
-
height: 1.2em;
-
}
-
-
.i-tabler-sun {
-
--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");
-
-webkit-mask: var(--un-icon) no-repeat;
-
mask: var(--un-icon) no-repeat;
-
-webkit-mask-size: 100% 100%;
-
mask-size: 100% 100%;
-
background-color: currentColor;
-
color: inherit;
-
width: 1.2em;
-
height: 1.2em;
-
}
-
.i-fluent-checkmark-circle-12-regular {
--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");
-webkit-mask: var(--un-icon) no-repeat;
···
width: 1.2em;
height: 1.2em;
}
+
+
.i-majesticons-settings-cog {
+
--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");
+
-webkit-mask: var(--un-icon) no-repeat;
+
mask: var(--un-icon) no-repeat;
+
-webkit-mask-size: 100% 100%;
+
mask-size: 100% 100%;
+
background-color: currentColor;
+
color: inherit;
+
width: 1.2em;
+
height: 1.2em;
+
}
+79 -1
src/utils/api.ts
···
import { DidDocument } from "@atcute/client/utils/did";
import { createStore } from "solid-js/store";
+
localStorage.constellationHost =
+
localStorage.constellationHost || "https://links.bsky.bad-example.com";
+
const didPDSCache: Record<string, string> = {};
const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({});
const didDocCache: Record<string, DidDocument> = {};
···
return pds;
};
-
export { getPDS, labelerCache, didDocCache, resolveHandle, resolvePDS };
+
interface LinkData {
+
links: {
+
[key: string]: {
+
[key: string]: {
+
records: number;
+
distinct_dids: number;
+
};
+
};
+
};
+
}
+
+
const getConstellation = async (
+
endpoint: string,
+
target: string,
+
collection?: string,
+
path?: string,
+
cursor?: string,
+
limit?: number,
+
) => {
+
const url = new URL(localStorage.constellationHost);
+
url.pathname = endpoint;
+
url.searchParams.set("target", target);
+
if (collection) {
+
if (!path)
+
throw new Error("collection and path must either both be set or neither");
+
url.searchParams.set("collection", collection);
+
url.searchParams.set("path", path);
+
} else {
+
if (path)
+
throw new Error("collection and path must either both be set or neither");
+
}
+
if (limit) url.searchParams.set("limit", `${limit}`);
+
if (cursor) url.searchParams.set("cursor", `${cursor}`);
+
const res = await fetch(url);
+
if (!res.ok) throw new Error("failed to fetch from constellation");
+
return await res.json();
+
};
+
+
const getAllBacklinks = (target: string) =>
+
getConstellation("/links/all", target);
+
+
const getRecordBacklinks = (
+
target: string,
+
collection: string,
+
path: string,
+
cursor?: string,
+
limit?: number,
+
) => getConstellation("/links", target, collection, path, cursor, limit || 100);
+
+
const getDidBacklinks = (
+
target: string,
+
collection: string,
+
path: string,
+
cursor?: string,
+
limit?: number,
+
) =>
+
getConstellation(
+
"/links/distinct-dids",
+
target,
+
collection,
+
path,
+
cursor,
+
limit || 100,
+
);
+
+
export {
+
getPDS,
+
getAllBacklinks,
+
getRecordBacklinks,
+
getDidBacklinks,
+
labelerCache,
+
didDocCache,
+
resolveHandle,
+
resolvePDS,
+
type LinkData,
+
};
+10
src/views/home.tsx
···
</A>
.
</p>
+
<p>
+
<A
+
href="https://links.bsky.bad-example.com"
+
class="text-lightblue-500 hover:underline"
+
target="_blank"
+
>
+
Backlinks
+
</A>{" "}
+
can be enabled in the settings.
+
</p>
</div>
<p>Examples:</p>
<div class="ml-2">
+41 -9
src/views/record.tsx
···
import { authenticate_post_with_doc } from "public-transport";
import { agent, loginState } from "../components/login.jsx";
import { Editor } from "../components/editor.jsx";
+
import { Backlinks } from "../components/backlinks.jsx";
import { editor } from "monaco-editor";
import { setCID, setValidRecord, validRecord } from "../components/navbar.jsx";
-
import { didDocCache, resolveHandle, resolvePDS } from "../utils/api.js";
-
import { theme } from "../layout.jsx";
+
import {
+
didDocCache,
+
getAllBacklinks,
+
LinkData,
+
resolveHandle,
+
resolvePDS,
+
} from "../utils/api.js";
+
import { theme } from "../components/settings.jsx";
import { AtUri, uriTemplates } from "../utils/templates.js";
const RecordView = () => {
const params = useParams();
const [record, setRecord] = createSignal<ComAtprotoRepoGetRecord.Output>();
+
const [backlinks, setBacklinks] = createSignal<{
+
links: LinkData;
+
target: string;
+
}>();
const [modal, setModal] = createSignal<HTMLDialogElement>();
const [openDelete, setOpenDelete] = createSignal(false);
const [openEdit, setOpenEdit] = createSignal(false);
···
if (err.message) setNotice(err.message);
else setNotice(`Invalid record: ${err}`);
setValidRecord(false);
+
}
+
if (localStorage.backlinks === "true") {
+
const backlinkTarget = `at://${did}/${params.collection}/${params.rkey}`;
+
const backlinks = await getAllBacklinks(backlinkTarget);
+
setBacklinks({ links: backlinks.links, target: backlinkTarget });
}
});
···
<option value="false">False</option>
</select>
</div>
-
<Editor theme={theme()} model={model!} />
+
<Editor theme={theme().color} model={model!} />
<div class="mt-2 flex flex-col gap-2">
<div class="text-red-500 dark:text-red-400">
{editNotice()}
···
</button>
</Show>
</div>
-
<div class="break-anywhere mt-1 whitespace-pre-wrap pl-3.5 font-mono text-sm sm:text-base">
-
<Show when={!JSONSyntax()}>
+
<Show when={!JSONSyntax()}>
+
<div
+
classList={{
+
"break-anywhere mb-2 mt-1 whitespace-pre-wrap pb-3 font-mono text-sm sm:text-base":
+
true,
+
"border-b border-neutral-500": !!backlinks(),
+
}}
+
>
<JSONValue
data={record()?.value as any}
repo={record()!.uri.split("/")[2]}
/>
+
</div>
+
<Show when={backlinks()}>
+
{(backlinks) => (
+
<Backlinks
+
links={backlinks().links}
+
target={backlinks().target}
+
/>
+
)}
</Show>
-
<Show when={JSONSyntax()}>
+
</Show>
+
<Show when={JSONSyntax()}>
+
<div class="mt-1">
<Editor
-
theme={theme()}
+
theme={theme().color}
model={editor.createModel(
JSON.stringify(record()?.value, null, 2).replace(
/[\u007F-\uFFFF]/g,
···
)}
readOnly={true}
/>
-
</Show>
-
</div>
+
</div>
+
</Show>
</Show>
</>
);
+26 -1
src/views/repo.tsx
···
import { createSignal, For, Show, createResource } from "solid-js";
import { CredentialManager, XRPC } from "@atcute/client";
import { A, query, useParams } from "@solidjs/router";
-
import { didDocCache, resolveHandle, resolvePDS } from "../utils/api.js";
+
import {
+
didDocCache,
+
getAllBacklinks,
+
LinkData,
+
resolveHandle,
+
resolvePDS,
+
} from "../utils/api.js";
import { DidDocument } from "@atcute/client/utils/did";
+
import { Backlinks } from "../components/backlinks.jsx";
const RepoView = () => {
const params = useParams();
const [didDoc, setDidDoc] = createSignal<DidDocument>();
+
const [backlinks, setBacklinks] = createSignal<{
+
links: LinkData;
+
target: string;
+
}>();
let rpc: XRPC;
let did = params.repo;
···
rpc = new XRPC({ handler: new CredentialManager({ service: pds }) });
const res = await describeRepo(did);
setDidDoc(didDocCache[did]);
+
if (localStorage.backlinks === "true") {
+
const backlinks = await getAllBacklinks(did);
+
setBacklinks({ links: backlinks.links, target: did });
+
}
return res.data;
};
···
PLC operation logs{" "}
<div class="i-tabler-external-link ml-0.5 text-xs" />
</a>
+
</Show>
+
<Show when={backlinks()}>
+
{(backlinks) => (
+
<div class="mt-2 border-t border-neutral-500 pt-2">
+
<Backlinks
+
links={backlinks().links}
+
target={backlinks().target}
+
/>
+
</div>
+
)}
</Show>
</div>
)}