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