1import * as TID from "@atcute/tid";
2import { createResource, createSignal, For, onMount, Show } from "solid-js";
3import {
4 getAllBacklinks,
5 getDidBacklinks,
6 getRecordBacklinks,
7 LinksWithDids,
8 LinksWithRecords,
9} from "../utils/api.js";
10import { localDateFromTimestamp } from "../utils/date.js";
11import { Button } from "./button.jsx";
12
13type Backlink = {
14 path: string;
15 counts: { distinct_dids: number; records: number };
16};
17
18const linksBySource = (links: Record<string, any>) => {
19 let out: Record<string, Backlink[]> = {};
20 Object.keys(links)
21 .toSorted()
22 .forEach((collection) => {
23 const paths = links[collection];
24 Object.keys(paths)
25 .toSorted()
26 .forEach((path) => {
27 if (paths[path].records === 0) return;
28 if (out[collection]) out[collection].push({ path, counts: paths[path] });
29 else out[collection] = [{ path, counts: paths[path] }];
30 });
31 });
32 return out;
33};
34
35const Backlinks = (props: { target: string }) => {
36 const fetchBacklinks = async () => {
37 const res = await getAllBacklinks(props.target);
38 return linksBySource(res.links);
39 };
40
41 const [response] = createResource(fetchBacklinks);
42
43 const [show, setShow] = createSignal<{
44 collection: string;
45 path: string;
46 showDids: boolean;
47 } | null>();
48
49 return (
50 <div class="flex w-full flex-col gap-1 text-sm wrap-anywhere">
51 <Show
52 when={response() && Object.keys(response()!).length}
53 fallback={<p>No backlinks found.</p>}
54 >
55 <For each={Object.keys(response()!)}>
56 {(collection) => (
57 <div>
58 <div class="flex items-center gap-1">
59 <span
60 title="Collection containing linking records"
61 class="iconify lucide--book-text shrink-0"
62 ></span>
63 {collection}
64 </div>
65 <For each={response()![collection]}>
66 {({ path, counts }) => (
67 <div class="ml-4.5">
68 <div class="flex items-center gap-1">
69 <span
70 title="Record path where the link is found"
71 class="iconify lucide--route shrink-0"
72 ></span>
73 {path.slice(1)}
74 </div>
75 <div class="ml-4.5">
76 <p>
77 <button
78 class="text-blue-400 hover:underline active:underline"
79 title="Show linking records"
80 onclick={() =>
81 (
82 show()?.collection === collection &&
83 show()?.path === path &&
84 !show()?.showDids
85 ) ?
86 setShow(null)
87 : setShow({ collection, path, showDids: false })
88 }
89 >
90 {counts.records} record{counts.records < 2 ? "" : "s"}
91 </button>
92 {" from "}
93 <button
94 class="text-blue-400 hover:underline active:underline"
95 title="Show linking DIDs"
96 onclick={() =>
97 (
98 show()?.collection === collection &&
99 show()?.path === path &&
100 show()?.showDids
101 ) ?
102 setShow(null)
103 : setShow({ collection, path, showDids: true })
104 }
105 >
106 {counts.distinct_dids} DID
107 {counts.distinct_dids < 2 ? "" : "s"}
108 </button>
109 </p>
110 <Show when={show()?.collection === collection && show()?.path === path}>
111 <Show when={show()?.showDids}>
112 <p class="w-full font-semibold">Distinct identities</p>
113 <BacklinkItems
114 target={props.target}
115 collection={collection}
116 path={path}
117 dids={true}
118 />
119 </Show>
120 <Show when={!show()?.showDids}>
121 <p class="w-full font-semibold">Records</p>
122 <BacklinkItems
123 target={props.target}
124 collection={collection}
125 path={path}
126 dids={false}
127 />
128 </Show>
129 </Show>
130 </div>
131 </div>
132 )}
133 </For>
134 </div>
135 )}
136 </For>
137 </Show>
138 </div>
139 );
140};
141
142// switching on !!did everywhere is pretty annoying, this could probably be two components
143// but i don't want to duplicate or think about how to extract the paging logic
144const BacklinkItems = ({
145 target,
146 collection,
147 path,
148 dids,
149 cursor,
150}: {
151 target: string;
152 collection: string;
153 path: string;
154 dids: boolean;
155 cursor?: string;
156}) => {
157 const [links, setLinks] = createSignal<LinksWithDids | LinksWithRecords>();
158 const [more, setMore] = createSignal<boolean>(false);
159
160 onMount(async () => {
161 const links = await (dids ? getDidBacklinks : getRecordBacklinks)(
162 target,
163 collection,
164 path,
165 cursor,
166 );
167 setLinks(links);
168 });
169
170 // TODO: could pass the `total` into this component, which can be checked against each call to this endpoint to find if it's stale.
171 // also hmm 'total' is misleading/wrong on that api
172
173 return (
174 <Show when={links()} fallback={<p>Loading…</p>}>
175 <Show when={dids}>
176 <For each={(links() as LinksWithDids).linking_dids}>
177 {(did) => (
178 <a
179 href={`/at://${did}`}
180 class="relative flex w-full font-mono text-blue-400 hover:underline active:underline"
181 >
182 {did}
183 </a>
184 )}
185 </For>
186 </Show>
187 <Show when={!dids}>
188 <For each={(links() as LinksWithRecords).linking_records}>
189 {({ did, collection, rkey }) => (
190 <p class="relative flex w-full items-center gap-1 font-mono">
191 <a
192 href={`/at://${did}/${collection}/${rkey}`}
193 class="text-blue-400 hover:underline active:underline"
194 >
195 {rkey}
196 </a>
197 <span class="text-xs text-neutral-500 dark:text-neutral-400">
198 {TID.validate(rkey) ?
199 localDateFromTimestamp(TID.parse(rkey).timestamp / 1000)
200 : undefined}
201 </span>
202 </p>
203 )}
204 </For>
205 </Show>
206 <Show when={links()?.cursor}>
207 <Show when={more()} fallback={<Button onClick={() => setMore(true)}>Load More</Button>}>
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
221export { Backlinks };