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 class="iconify lucide--book-text shrink-0"></span>
60 {collection}
61 </div>
62 <For each={response()![collection]}>
63 {({ path, counts }) => (
64 <div class="ml-4.5">
65 <div class="flex items-center gap-1">
66 <span class="iconify lucide--route shrink-0"></span>
67 {path.slice(1)}
68 </div>
69 <div class="ml-4.5">
70 <p>
71 <button
72 class="text-blue-400 hover:underline active:underline"
73 onclick={() =>
74 (
75 show()?.collection === collection &&
76 show()?.path === path &&
77 !show()?.showDids
78 ) ?
79 setShow(null)
80 : setShow({ collection, path, showDids: false })
81 }
82 >
83 {counts.records} record{counts.records < 2 ? "" : "s"}
84 </button>
85 {" from "}
86 <button
87 class="text-blue-400 hover:underline active:underline"
88 onclick={() =>
89 (
90 show()?.collection === collection &&
91 show()?.path === path &&
92 show()?.showDids
93 ) ?
94 setShow(null)
95 : setShow({ collection, path, showDids: true })
96 }
97 >
98 {counts.distinct_dids} DID
99 {counts.distinct_dids < 2 ? "" : "s"}
100 </button>
101 </p>
102 <Show when={show()?.collection === collection && show()?.path === path}>
103 <Show when={show()?.showDids}>
104 <p class="w-full font-semibold">Distinct identities</p>
105 <BacklinkItems
106 target={props.target}
107 collection={collection}
108 path={path}
109 dids={true}
110 />
111 </Show>
112 <Show when={!show()?.showDids}>
113 <p class="w-full font-semibold">Records</p>
114 <BacklinkItems
115 target={props.target}
116 collection={collection}
117 path={path}
118 dids={false}
119 />
120 </Show>
121 </Show>
122 </div>
123 </div>
124 )}
125 </For>
126 </div>
127 )}
128 </For>
129 </Show>
130 </div>
131 );
132};
133
134// switching on !!did everywhere is pretty annoying, this could probably be two components
135// but i don't want to duplicate or think about how to extract the paging logic
136const BacklinkItems = ({
137 target,
138 collection,
139 path,
140 dids,
141 cursor,
142}: {
143 target: string;
144 collection: string;
145 path: string;
146 dids: boolean;
147 cursor?: string;
148}) => {
149 const [links, setLinks] = createSignal<LinksWithDids | LinksWithRecords>();
150 const [more, setMore] = createSignal<boolean>(false);
151
152 onMount(async () => {
153 const links = await (dids ? getDidBacklinks : getRecordBacklinks)(
154 target,
155 collection,
156 path,
157 cursor,
158 );
159 setLinks(links);
160 });
161
162 // TODO: could pass the `total` into this component, which can be checked against each call to this endpoint to find if it's stale.
163 // also hmm 'total' is misleading/wrong on that api
164
165 return (
166 <Show when={links()} fallback={<p>Loading…</p>}>
167 <Show when={dids}>
168 <For each={(links() as LinksWithDids).linking_dids}>
169 {(did) => (
170 <a
171 href={`/at://${did}`}
172 class="relative flex w-full font-mono text-blue-400 hover:underline active:underline"
173 >
174 {did}
175 </a>
176 )}
177 </For>
178 </Show>
179 <Show when={!dids}>
180 <For each={(links() as LinksWithRecords).linking_records}>
181 {({ did, collection, rkey }) => (
182 <p class="relative flex w-full items-center gap-1 font-mono">
183 <a
184 href={`/at://${did}/${collection}/${rkey}`}
185 class="text-blue-400 hover:underline active:underline"
186 >
187 {rkey}
188 </a>
189 <span class="text-xs text-neutral-500 dark:text-neutral-400">
190 {TID.validate(rkey) ?
191 localDateFromTimestamp(TID.parse(rkey).timestamp / 1000)
192 : undefined}
193 </span>
194 </p>
195 )}
196 </For>
197 </Show>
198 <Show when={links()?.cursor}>
199 <Show when={more()} fallback={<Button onClick={() => setMore(true)}>Load More</Button>}>
200 <BacklinkItems
201 target={target}
202 collection={collection}
203 path={path}
204 dids={dids}
205 cursor={links()!.cursor}
206 />
207 </Show>
208 </Show>
209 </Show>
210 );
211};
212
213export { Backlinks };