forked from
pds.ls/pdsls
atproto explorer
1import { Client, simpleFetchHandler } from "@atcute/client";
2import { DidDocument } from "@atcute/identity";
3import { ActorIdentifier, Did, Handle, Nsid } from "@atcute/lexicons";
4import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
5import {
6 createEffect,
7 createResource,
8 createSignal,
9 ErrorBoundary,
10 For,
11 onMount,
12 Show,
13 Suspense,
14} from "solid-js";
15import { createStore } from "solid-js/store";
16import { Backlinks } from "../components/backlinks.jsx";
17import {
18 ActionMenu,
19 CopyMenu,
20 DropdownMenu,
21 MenuProvider,
22 MenuSeparator,
23 NavMenu,
24} from "../components/dropdown.jsx";
25import { setPDS } from "../components/navbar.jsx";
26import {
27 addNotification,
28 removeNotification,
29 updateNotification,
30} from "../components/notification.jsx";
31import { TextInput } from "../components/text-input.jsx";
32import Tooltip from "../components/tooltip.jsx";
33import {
34 didDocCache,
35 labelerCache,
36 resolveHandle,
37 resolveLexiconAuthority,
38 resolvePDS,
39 validateHandle,
40} from "../utils/api.js";
41import { detectDidKeyType, detectKeyType } from "../utils/key.js";
42import { BlobView } from "./blob.jsx";
43import { PlcLogView } from "./logs.jsx";
44
45export const RepoView = () => {
46 const params = useParams();
47 const location = useLocation();
48 const navigate = useNavigate();
49 const [error, setError] = createSignal<string>();
50 const [downloading, setDownloading] = createSignal(false);
51 const [didDoc, setDidDoc] = createSignal<DidDocument>();
52 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>();
53 const [filter, setFilter] = createSignal<string>();
54 const [showFilter, setShowFilter] = createSignal(false);
55 const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({});
56 const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]);
57 let rpc: Client;
58 let pds: string;
59 const did = params.repo!;
60
61 // Handle scrolling to a collection group when hash is like #collections:app.bsky
62 createEffect(() => {
63 const hash = location.hash;
64 if (hash.startsWith("#collections:")) {
65 const authority = hash.slice(13);
66 requestAnimationFrame(() => {
67 const element = document.getElementById(`collection-${authority}`);
68 if (element) element.scrollIntoView({ behavior: "instant", block: "start" });
69 });
70 }
71 });
72
73 const RepoTab = (props: {
74 tab: "collections" | "backlinks" | "identity" | "blobs" | "logs";
75 label: string;
76 }) => {
77 const isActive = () => {
78 if (!location.hash) {
79 if (!error() && props.tab === "collections") return true;
80 if (!!error() && props.tab === "identity") return true;
81 return false;
82 }
83 if (props.tab === "collections")
84 return location.hash === "#collections" || location.hash.startsWith("#collections:");
85 return location.hash === `#${props.tab}`;
86 };
87
88 return (
89 <A
90 classList={{
91 "border-b-2 font-medium": true,
92 "border-transparent text-neutral-600 dark:text-neutral-300/80 hover:border-neutral-600 dark:hover:border-neutral-300/80":
93 !isActive(),
94 }}
95 href={`/at://${params.repo}#${props.tab}`}
96 >
97 {props.label}
98 </A>
99 );
100 };
101
102 const getRotationKeys = async () => {
103 const res = await fetch(
104 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/last`,
105 );
106 const json = await res.json();
107 setRotationKeys(json.rotationKeys ?? []);
108 };
109
110 const fetchRepo = async () => {
111 try {
112 pds = await resolvePDS(did);
113 } catch {
114 if (!did.startsWith("did:")) {
115 try {
116 const did = await resolveHandle(params.repo as Handle);
117 navigate(location.pathname.replace(params.repo!, did), { replace: true });
118 return;
119 } catch {
120 try {
121 const nsid = params.repo as Nsid;
122 const res = await resolveLexiconAuthority(nsid);
123 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`, { replace: true });
124 return;
125 } catch {
126 navigate(`/${did}`, { replace: true });
127 return;
128 }
129 }
130 }
131 }
132 setDidDoc(didDocCache[did] as DidDocument);
133 getRotationKeys();
134
135 validateHandles();
136
137 if (!pds) {
138 setError("Missing PDS");
139 setPDS("Missing PDS");
140 return {};
141 }
142
143 rpc = new Client({ handler: simpleFetchHandler({ service: pds }) });
144 try {
145 const res = await rpc.get("com.atproto.repo.describeRepo", {
146 params: { repo: did as ActorIdentifier },
147 });
148 if (res.ok) {
149 const collections: Record<string, { hidden: boolean; nsids: string[] }> = {};
150 res.data.collections.forEach((c) => {
151 const nsid = c.split(".");
152 if (nsid.length > 2) {
153 const authority = `${nsid[0]}.${nsid[1]}`;
154 collections[authority] = {
155 nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")),
156 hidden: false,
157 };
158 }
159 });
160 setNsids(collections);
161 } else {
162 console.error(res.data.error);
163 switch (res.data.error) {
164 case "RepoDeactivated":
165 setError("Deactivated");
166 break;
167 case "RepoTakendown":
168 setError("Takendown");
169 break;
170 default:
171 setError("Unreachable");
172 }
173 }
174
175 return res.data;
176 } catch {
177 return {};
178 }
179 };
180
181 const [repo] = createResource(fetchRepo);
182
183 const validateHandles = async () => {
184 for (const alias of didDoc()?.alsoKnownAs ?? []) {
185 if (alias.startsWith("at://"))
186 setValidHandles(
187 alias,
188 await validateHandle(alias.replace("at://", "") as Handle, did as Did),
189 );
190 }
191 };
192
193 const downloadRepo = async () => {
194 let notificationId: string | null = null;
195
196 try {
197 setDownloading(true);
198 notificationId = addNotification({
199 message: "Downloading repository...",
200 progress: 0,
201 total: 0,
202 type: "info",
203 });
204
205 const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`);
206 if (!response.ok) {
207 throw new Error(`HTTP error status: ${response.status}`);
208 }
209
210 const contentLength = response.headers.get("content-length");
211 const total = contentLength ? parseInt(contentLength, 10) : 0;
212 let loaded = 0;
213
214 const reader = response.body?.getReader();
215 const chunks: BlobPart[] = [];
216
217 if (reader) {
218 while (true) {
219 const { done, value } = await reader.read();
220 if (done) break;
221
222 chunks.push(value);
223 loaded += value.length;
224
225 if (total > 0) {
226 const progress = Math.round((loaded / total) * 100);
227 updateNotification(notificationId, {
228 progress,
229 total,
230 });
231 } else {
232 const progressMB = Math.round((loaded / (1024 * 1024)) * 10) / 10;
233 updateNotification(notificationId, {
234 progress: progressMB,
235 total: 0,
236 });
237 }
238 }
239 }
240
241 const blob = new Blob(chunks);
242 const url = window.URL.createObjectURL(blob);
243 const a = document.createElement("a");
244 a.href = url;
245 a.download = `${did}-${new Date().toISOString()}.car`;
246 document.body.appendChild(a);
247 a.click();
248
249 window.URL.revokeObjectURL(url);
250 document.body.removeChild(a);
251
252 updateNotification(notificationId, {
253 message: "Repository downloaded successfully",
254 type: "success",
255 progress: undefined,
256 });
257 setTimeout(() => {
258 if (notificationId) removeNotification(notificationId);
259 }, 3000);
260 } catch (error) {
261 console.error("Download failed:", error);
262 if (notificationId) {
263 updateNotification(notificationId, {
264 message: "Download failed",
265 type: "error",
266 progress: undefined,
267 });
268 setTimeout(() => {
269 if (notificationId) removeNotification(notificationId);
270 }, 5000);
271 }
272 }
273 setDownloading(false);
274 };
275
276 return (
277 <Show when={repo()}>
278 <div class="flex w-full flex-col gap-3 wrap-break-word">
279 <div class="flex justify-between px-2 text-sm sm:text-base">
280 <div class="flex items-center gap-3 sm:gap-4">
281 <Show when={!error()}>
282 <RepoTab tab="collections" label="Collections" />
283 </Show>
284 <RepoTab tab="identity" label="Identity" />
285 <Show when={did.startsWith("did:plc")}>
286 <RepoTab tab="logs" label="Logs" />
287 </Show>
288 <Show when={!error()}>
289 <RepoTab tab="blobs" label="Blobs" />
290 </Show>
291 <RepoTab tab="backlinks" label="Backlinks" />
292 </div>
293 <div class="flex gap-1">
294 <Show when={error() && error() !== "Missing PDS"}>
295 <div class="flex items-center gap-1 text-red-500 dark:text-red-400">
296 <span class="iconify lucide--alert-triangle"></span>
297 <span>{error()}</span>
298 </div>
299 </Show>
300 <MenuProvider>
301 <DropdownMenu icon="lucide--ellipsis-vertical" buttonClass="rounded-sm p-1.5">
302 <Show
303 when={!error() && (!location.hash || location.hash.startsWith("#collections"))}
304 >
305 <ActionMenu
306 label="Filter collections"
307 icon="lucide--filter"
308 onClick={() => setShowFilter(!showFilter())}
309 />
310 </Show>
311 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" />
312 <NavMenu
313 href={`/jetstream?dids=${params.repo}`}
314 label="Jetstream"
315 icon="lucide--radio-tower"
316 />
317 <Show when={params.repo && params.repo in labelerCache}>
318 <NavMenu
319 href={`/labels?did=${params.repo}&uriPatterns=*`}
320 label="Labels"
321 icon="lucide--tag"
322 />
323 </Show>
324 <Show when={error()?.length === 0 || error() === undefined}>
325 <ActionMenu
326 label="Export repo"
327 icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"}
328 onClick={() => downloadRepo()}
329 />
330 </Show>
331 <MenuSeparator />
332 <NavMenu
333 href={
334 did.startsWith("did:plc") ?
335 `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}`
336 : `https://${did.split("did:web:")[1]}/.well-known/did.json`
337 }
338 newTab
339 label="DID document"
340 icon="lucide--external-link"
341 />
342 <Show when={did.startsWith("did:plc")}>
343 <NavMenu
344 href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`}
345 newTab
346 label="Audit log"
347 icon="lucide--external-link"
348 />
349 </Show>
350 </DropdownMenu>
351 </MenuProvider>
352 </div>
353 </div>
354 <div class="flex w-full flex-col gap-1 px-2">
355 <Show when={location.hash === "#logs"}>
356 <ErrorBoundary
357 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
358 >
359 <Suspense
360 fallback={
361 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" />
362 }
363 >
364 <PlcLogView did={did} />
365 </Suspense>
366 </ErrorBoundary>
367 </Show>
368 <Show when={location.hash === "#backlinks"}>
369 <ErrorBoundary
370 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
371 >
372 <Suspense
373 fallback={
374 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" />
375 }
376 >
377 <Backlinks target={did} />
378 </Suspense>
379 </ErrorBoundary>
380 </Show>
381 <Show when={location.hash === "#blobs"}>
382 <ErrorBoundary
383 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
384 >
385 <Suspense
386 fallback={
387 <div class="iconify lucide--loader-circle mt-2 animate-spin self-center text-xl" />
388 }
389 >
390 <BlobView pds={pds!} repo={did} />
391 </Suspense>
392 </ErrorBoundary>
393 </Show>
394 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}>
395 <Show when={showFilter()}>
396 <TextInput
397 name="filter"
398 placeholder="Filter collections"
399 onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())}
400 class="grow"
401 ref={(node) => {
402 onMount(() => node.focus());
403 }}
404 />
405 </Show>
406 <div class="flex flex-col text-sm wrap-anywhere" classList={{ "-mt-1": !showFilter() }}>
407 <Show
408 when={Object.keys(nsids() ?? {}).length != 0}
409 fallback={<span class="mt-3 text-center text-base">No collections found.</span>}
410 >
411 <For
412 each={Object.keys(nsids() ?? {}).filter((authority) =>
413 filter() ?
414 authority.includes(filter()!) ||
415 nsids()?.[authority].nsids.some((nsid) =>
416 `${authority}.${nsid}`.includes(filter()!),
417 )
418 : true,
419 )}
420 >
421 {(authority) => {
422 const reversedDomain = authority.split(".").reverse().join(".");
423 const [faviconLoaded, setFaviconLoaded] = createSignal(false);
424
425 const isHighlighted = () => location.hash === `#collections:${authority}`;
426
427 return (
428 <div
429 id={`collection-${authority}`}
430 class="group flex items-start gap-2 rounded-lg p-1 transition-colors"
431 classList={{
432 "dark:hover:bg-dark-200 hover:bg-neutral-200": !isHighlighted(),
433 "bg-blue-100 dark:bg-blue-500/25": isHighlighted(),
434 }}
435 >
436 <a
437 href={`#collections:${authority}`}
438 class="relative flex h-5 w-4 shrink-0 items-center justify-center hover:opacity-70"
439 >
440 <span class="absolute top-1/2 -left-5 flex -translate-y-1/2 items-center text-base opacity-0 transition-opacity group-hover:opacity-100">
441 <span class="iconify lucide--link absolute -left-2 w-7"></span>
442 </span>
443 <Show when={!faviconLoaded()}>
444 <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" />
445 </Show>
446 <img
447 src={
448 ["bsky.app", "bsky.chat"].includes(reversedDomain) ?
449 "https://web-cdn.bsky.app/static/apple-touch-icon.png"
450 : `https://${reversedDomain}/favicon.ico`
451 }
452 alt={`${reversedDomain} favicon`}
453 class="h-4 w-4"
454 classList={{ hidden: !faviconLoaded() }}
455 onLoad={() => setFaviconLoaded(true)}
456 onError={() => setFaviconLoaded(false)}
457 />
458 </a>
459 <div class="flex flex-1 flex-col">
460 <For
461 each={nsids()?.[authority].nsids.filter((nsid) =>
462 filter() ? `${authority}.${nsid}`.includes(filter()!) : true,
463 )}
464 >
465 {(nsid) => (
466 <A
467 href={`/at://${did}/${authority}.${nsid}`}
468 class="hover:underline active:underline"
469 >
470 <span>{authority}</span>
471 <span class="text-neutral-500 dark:text-neutral-400">.{nsid}</span>
472 </A>
473 )}
474 </For>
475 </div>
476 </div>
477 );
478 }}
479 </For>
480 </Show>
481 </div>
482 </Show>
483 <Show when={location.hash === "#identity" || (error() && !location.hash)}>
484 <Show when={didDoc()}>
485 {(didDocument) => (
486 <div class="flex flex-col gap-3 wrap-anywhere">
487 {/* ID Section */}
488 <div>
489 <div class="font-semibold">DID</div>
490 <div class="text-sm text-neutral-700 dark:text-neutral-300">
491 {didDocument().id}
492 </div>
493 </div>
494
495 {/* Aliases Section */}
496 <div>
497 <p class="font-semibold">Aliases</p>
498 <For each={didDocument().alsoKnownAs}>
499 {(alias) => (
500 <div class="flex items-center gap-1 text-sm text-neutral-700 dark:text-neutral-300">
501 <span>{alias}</span>
502 <Show when={alias.startsWith("at://")}>
503 <Tooltip
504 text={
505 validHandles[alias] === true ? "Valid handle"
506 : validHandles[alias] === undefined ?
507 "Validating"
508 : "Invalid handle"
509 }
510 >
511 <span
512 classList={{
513 "iconify lucide--check text-green-600 dark:text-green-400":
514 validHandles[alias] === true,
515 "iconify lucide--x text-red-500 dark:text-red-400":
516 validHandles[alias] === false,
517 "iconify lucide--loader-circle animate-spin":
518 validHandles[alias] === undefined,
519 }}
520 ></span>
521 </Tooltip>
522 </Show>
523 </div>
524 )}
525 </For>
526 </div>
527
528 {/* Services Section */}
529 <div>
530 <p class="font-semibold">Services</p>
531 <div class="flex flex-col gap-1">
532 <For each={didDocument().service}>
533 {(service) => (
534 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300">
535 <span class="iconify lucide--hash"></span>
536 <span>{service.id.split("#")[1]}</span>
537 <span></span>
538 <a
539 class="w-fit underline hover:text-blue-400"
540 href={service.serviceEndpoint.toString()}
541 target="_blank"
542 rel="noopener"
543 >
544 {service.serviceEndpoint.toString()}
545 </a>
546 </div>
547 )}
548 </For>
549 </div>
550 </div>
551
552 {/* Verification Methods Section */}
553 <div>
554 <p class="font-semibold">Verification Methods</p>
555 <div class="flex flex-col gap-1">
556 <For each={didDocument().verificationMethod}>
557 {(verif) => (
558 <Show when={verif.publicKeyMultibase}>
559 {(key) => (
560 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300">
561 <span class="iconify lucide--hash"></span>
562 <div class="flex items-center gap-2">
563 <span>{verif.id.split("#")[1]}</span>
564 <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
565 <span class="iconify lucide--key-round"></span>
566 <span>{detectKeyType(key())}</span>
567 </div>
568 </div>
569 <span></span>
570 <div class="font-mono break-all">{key()}</div>
571 </div>
572 )}
573 </Show>
574 )}
575 </For>
576 </div>
577 </div>
578
579 {/* Rotation Keys Section */}
580 <Show when={rotationKeys().length > 0}>
581 <div>
582 <p class="font-semibold">Rotation Keys</p>
583 <div class="flex flex-col gap-1">
584 <For each={rotationKeys()}>
585 {(key) => (
586 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300">
587 <span class="iconify lucide--key-round text-neutral-500 dark:text-neutral-400"></span>
588 <span class="text-neutral-500 dark:text-neutral-400">
589 {detectDidKeyType(key)}
590 </span>
591 <span></span>
592 <div class="font-mono break-all">{key.replace("did:key:", "")}</div>
593 </div>
594 )}
595 </For>
596 </div>
597 </div>
598 </Show>
599 </div>
600 )}
601 </Show>
602 </Show>
603 </div>
604 </div>
605 </Show>
606 );
607};