forked from
pds.ls/pdsls
atproto explorer
1import * as TID from "@atcute/tid";
2import { createResource, createSignal, For, onMount, Show } from "solid-js";
3import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../utils/api.js";
4import { localDateFromTimestamp } from "../utils/date.js";
5import { Button } from "./button.jsx";
6
7type BacklinksProps = {
8 target: string;
9 collection: string;
10 path: string;
11};
12
13type BacklinkEntry = {
14 collection: string;
15 path: string;
16 counts: { distinct_dids: number; records: number };
17};
18
19const flattenLinks = (links: Record<string, any>): BacklinkEntry[] => {
20 const entries: BacklinkEntry[] = [];
21 Object.keys(links)
22 .toSorted()
23 .forEach((collection) => {
24 const paths = links[collection];
25 Object.keys(paths)
26 .toSorted()
27 .forEach((path) => {
28 if (paths[path].records > 0) {
29 entries.push({ collection, path, counts: paths[path] });
30 }
31 });
32 });
33 return entries;
34};
35
36const BacklinkRecords = (props: BacklinksProps & { cursor?: string }) => {
37 const [links, setLinks] = createSignal<LinksWithRecords>();
38 const [more, setMore] = createSignal(false);
39
40 onMount(async () => {
41 const res = await getRecordBacklinks(props.target, props.collection, props.path, props.cursor);
42 setLinks(res);
43 });
44
45 return (
46 <Show when={links()} fallback={<p class="px-3 py-2 text-neutral-500">Loading…</p>}>
47 <For each={links()!.linking_records}>
48 {({ did, collection, rkey }) => {
49 const timestamp =
50 TID.validate(rkey) ? localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) : null;
51 return (
52 <a
53 href={`/at://${did}/${collection}/${rkey}`}
54 class="grid grid-cols-[auto_1fr_auto] items-center gap-x-1 px-2 py-1.5 font-mono text-xs select-none hover:bg-neutral-200/50 active:bg-neutral-200/50 sm:gap-x-3 sm:px-3 dark:hover:bg-neutral-700/50 dark:active:bg-neutral-700/50"
55 >
56 <span class="text-blue-500 dark:text-blue-400">{rkey}</span>
57 <span class="truncate text-neutral-700 dark:text-neutral-300" title={did}>
58 {did}
59 </span>
60 <span class="text-neutral-500 tabular-nums dark:text-neutral-400">
61 {timestamp ?? ""}
62 </span>
63 </a>
64 );
65 }}
66 </For>
67 <Show when={links()?.cursor}>
68 <Show
69 when={more()}
70 fallback={
71 <div class="p-2">
72 <Button
73 onClick={() => setMore(true)}
74 class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-full items-center justify-center gap-1 rounded border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
75 >
76 Load More
77 </Button>
78 </div>
79 }
80 >
81 <BacklinkRecords
82 target={props.target}
83 collection={props.collection}
84 path={props.path}
85 cursor={links()!.cursor}
86 />
87 </Show>
88 </Show>
89 </Show>
90 );
91};
92
93const Backlinks = (props: { target: string }) => {
94 const [response] = createResource(async () => {
95 const res = await getAllBacklinks(props.target);
96 return flattenLinks(res.links);
97 });
98
99 return (
100 <div class="flex w-full flex-col gap-3 text-sm">
101 <Show when={response()} fallback={<p class="text-neutral-500">Loading…</p>}>
102 <Show when={response()!.length === 0}>
103 <p class="text-neutral-500">No backlinks found.</p>
104 </Show>
105 <For each={response()}>
106 {(entry) => (
107 <BacklinkSection
108 target={props.target}
109 collection={entry.collection}
110 path={entry.path}
111 counts={entry.counts}
112 />
113 )}
114 </For>
115 </Show>
116 </div>
117 );
118};
119
120const BacklinkSection = (
121 props: BacklinksProps & { counts: { distinct_dids: number; records: number } },
122) => {
123 const [expanded, setExpanded] = createSignal(false);
124
125 return (
126 <div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700">
127 <button
128 class="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
129 onClick={() => setExpanded(!expanded())}
130 >
131 <div class="flex min-w-0 flex-1 flex-col">
132 <span class="w-full truncate">{props.collection}</span>
133 <span class="w-full text-xs wrap-break-word text-neutral-500 dark:text-neutral-400">
134 {props.path.slice(1)}
135 </span>
136 </div>
137 <div class="flex shrink-0 items-center gap-2 text-neutral-700 dark:text-neutral-300">
138 <span class="text-xs">
139 {props.counts.records} from {props.counts.distinct_dids} repo
140 {props.counts.distinct_dids > 1 ? "s" : ""}
141 </span>
142 <span
143 class="iconify lucide--chevron-down transition-transform"
144 classList={{ "rotate-180": expanded() }}
145 />
146 </div>
147 </button>
148 <Show when={expanded()}>
149 <div class="border-t border-neutral-200 bg-neutral-50/50 dark:border-neutral-700 dark:bg-neutral-800/30">
150 <BacklinkRecords target={props.target} collection={props.collection} path={props.path} />
151 </div>
152 </Show>
153 </div>
154 );
155};
156
157export { Backlinks };