forked from
pds.ls/pdsls
atmosphere explorer
1import * as CAR from "@atcute/car";
2import * as CBOR from "@atcute/cbor";
3import * as CID from "@atcute/cid";
4import { Did } from "@atcute/lexicons";
5import { fromStream, isCommit } from "@atcute/repo";
6import * as TID from "@atcute/tid";
7import { Title } from "@solidjs/meta";
8import { useLocation, useNavigate } from "@solidjs/router";
9import {
10 createEffect,
11 createMemo,
12 createSignal,
13 For,
14 Match,
15 Show,
16 Switch,
17 untrack,
18} from "solid-js";
19import { Button } from "../../components/button.jsx";
20import { Favicon } from "../../components/favicon.jsx";
21import HoverCard from "../../components/hover-card/base";
22import { JSONValue } from "../../components/json.jsx";
23import { TextInput } from "../../components/text-input.jsx";
24import { didDocCache, resolveDidDoc } from "../../utils/api.js";
25import { localDateFromTimestamp } from "../../utils/date.js";
26import { createDebouncedValue } from "../../utils/hooks/debounced.js";
27import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js";
28import {
29 type Archive,
30 type CollectionEntry,
31 type RecordEntry,
32 type View,
33 toJsonValue,
34 WelcomeView,
35} from "./shared.jsx";
36
37const viewToHash = (view: View): string => {
38 switch (view.type) {
39 case "repo":
40 return "";
41 case "collection":
42 return `#${view.collection.name}`;
43 case "record":
44 return `#${view.collection.name}/${view.record.key}`;
45 }
46};
47
48const hashToView = (hash: string, archive: Archive): View => {
49 if (!hash || hash === "#") return { type: "repo" };
50
51 const raw = hash.startsWith("#") ? hash.slice(1) : hash;
52 const slashIdx = raw.indexOf("/");
53
54 if (slashIdx === -1) {
55 const collection = archive.entries.find((e) => e.name === raw);
56 if (collection) return { type: "collection", collection };
57 return { type: "repo" };
58 }
59
60 const collectionName = raw.slice(0, slashIdx);
61 const recordKey = raw.slice(slashIdx + 1);
62 const collection = archive.entries.find((e) => e.name === collectionName);
63 if (collection) {
64 const record = collection.entries.find((r) => r.key === recordKey);
65 if (record) return { type: "record", collection, record };
66 return { type: "collection", collection };
67 }
68
69 return { type: "repo" };
70};
71
72export const ExploreToolView = () => {
73 const location = useLocation();
74 const navigate = useNavigate();
75
76 const [archive, setArchive] = createSignal<Archive | null>(null);
77 const [loading, setLoading] = createSignal(false);
78 const [progress, setProgress] = createSignal(0);
79 const [error, setError] = createSignal<string>();
80
81 const view = createMemo((): View => {
82 const arch = archive();
83 if (!arch) return { type: "repo" };
84 return hashToView(location.hash, arch);
85 });
86
87 const navigateToView = (newView: View) => {
88 const hash = viewToHash(newView);
89 navigate(`${location.pathname}${hash}`);
90 };
91
92 const parseCarFile = async (file: File) => {
93 setLoading(true);
94 setProgress(0);
95 setError(undefined);
96
97 try {
98 // Read file as ArrayBuffer to extract DID from commit block
99 const buffer = new Uint8Array(await file.arrayBuffer());
100 const car = CAR.fromUint8Array(buffer);
101
102 // Get DID from commit block
103 let did = "Repository";
104 const rootCid = car.roots[0]?.$link;
105 if (rootCid) {
106 for (const entry of car) {
107 try {
108 if (CID.toString(entry.cid) === rootCid) {
109 const commit = CBOR.decode(entry.bytes);
110 if (isCommit(commit)) {
111 did = commit.did;
112 }
113 break;
114 }
115 } catch {
116 // Skip entries with invalid CIDs
117 }
118 }
119 }
120
121 const collections = new Map<string, RecordEntry[]>();
122 const result: Archive = {
123 file,
124 did,
125 entries: [],
126 };
127
128 const stream = file.stream();
129 const repo = fromStream(stream);
130 try {
131 let count = 0;
132 for await (const entry of repo) {
133 try {
134 let list = collections.get(entry.collection);
135 if (list === undefined) {
136 collections.set(entry.collection, (list = []));
137 result.entries.push({
138 name: entry.collection,
139 entries: list,
140 });
141 }
142
143 const record = toJsonValue(entry.record);
144 list.push({
145 key: entry.rkey,
146 cid: entry.cid.$link,
147 record,
148 });
149
150 if (++count % 10000 === 0) {
151 setProgress(count);
152 await new Promise((resolve) => setTimeout(resolve, 0));
153 }
154 } catch {
155 // Skip entries with invalid data
156 }
157 }
158 } finally {
159 await repo.dispose();
160 }
161
162 // Resolve DID document to populate handle in cache
163 if (did !== "Repository") {
164 try {
165 const doc = await resolveDidDoc(did as Did);
166 didDocCache[did] = doc;
167 } catch (err) {
168 console.error("Failed to resolve DID document:", err);
169 }
170 }
171
172 setArchive(result);
173 if (location.hash) navigate(location.pathname, { replace: true });
174 } catch (err) {
175 console.error("Failed to parse CAR file:", err);
176 setError(err instanceof Error ? err.message : "Failed to parse CAR file");
177 } finally {
178 setLoading(false);
179 }
180 };
181
182 const handleFileChange = createFileChangeHandler(parseCarFile);
183 const handleDrop = createDropHandler(parseCarFile);
184
185 const reset = () => {
186 setArchive(null);
187 setError(undefined);
188 if (location.hash) navigate(location.pathname, { replace: true });
189 };
190
191 return (
192 <>
193 <Title>Explore archive - PDSls</Title>
194 <Show
195 when={archive()}
196 fallback={
197 <WelcomeView
198 title="Explore archive"
199 subtitle="Upload a CAR file to explore its contents."
200 loading={loading()}
201 progress={progress()}
202 error={error()}
203 onFileChange={handleFileChange}
204 onDrop={handleDrop}
205 onDragOver={handleDragOver}
206 />
207 }
208 >
209 {(arch) => (
210 <ExploreView archive={arch()} view={view} setView={navigateToView} onClose={reset} />
211 )}
212 </Show>
213 </>
214 );
215};
216
217const ExploreView = (props: {
218 archive: Archive;
219 view: () => View;
220 setView: (view: View) => void;
221 onClose: () => void;
222}) => {
223 const handle =
224 didDocCache[props.archive.did]?.alsoKnownAs
225 ?.filter((alias) => alias.startsWith("at://"))[0]
226 ?.split("at://")[1] ?? props.archive.did;
227
228 return (
229 <div class="flex w-full flex-col">
230 <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base">
231 {/* DID / Repository Level */}
232 <div class="group relative flex items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40">
233 <Show
234 when={props.view().type !== "repo"}
235 fallback={
236 <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7">
237 <span class="iconify lucide--book-user shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" />
238 <span class="flex min-w-0 gap-1 py-0.5 font-medium">
239 <Show
240 when={handle !== props.archive.did}
241 fallback={<span class="truncate">{props.archive.did}</span>}
242 >
243 <span class="shrink-0">{handle}</span>
244 <span class="truncate text-neutral-500 dark:text-neutral-400">
245 ({props.archive.did})
246 </span>
247 </Show>
248 </span>
249 </div>
250 }
251 >
252 <button
253 type="button"
254 onClick={() => props.setView({ type: "repo" })}
255 class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7"
256 >
257 <span class="iconify lucide--book-user shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" />
258 <span class="flex min-w-0 gap-1 py-0.5 font-medium text-blue-500 transition-colors duration-150 group-hover:text-blue-600 dark:text-blue-400 dark:group-hover:text-blue-300">
259 <Show
260 when={handle !== props.archive.did}
261 fallback={<span class="truncate">{props.archive.did}</span>}
262 >
263 <span class="shrink-0">{handle}</span>
264 <span class="truncate">({props.archive.did})</span>
265 </Show>
266 </span>
267 </button>
268 </Show>
269 <button
270 type="button"
271 onClick={props.onClose}
272 title="Close and upload a different file"
273 class="flex shrink-0 items-center rounded px-2 py-1 text-neutral-500 transition-all duration-200 hover:bg-neutral-200/70 hover:text-neutral-600 active:bg-neutral-300/70 sm:py-1.5 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-300 dark:active:bg-neutral-600/70"
274 >
275 <span class="iconify lucide--x" />
276 </button>
277 </div>
278
279 {/* Collection Level */}
280 <Show
281 when={(() => {
282 const v = props.view();
283 return v.type === "collection" || v.type === "record" ? v.collection : null;
284 })()}
285 >
286 {(collection) => (
287 <Show
288 when={props.view().type === "record"}
289 fallback={
290 <div class="group relative flex items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent px-2 transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40">
291 <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7">
292 <span class="iconify lucide--folder-open shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" />
293 <span class="truncate py-0.5 font-medium">{collection().name}</span>
294 </div>
295 </div>
296 }
297 >
298 <button
299 type="button"
300 onClick={() => props.setView({ type: "collection", collection: collection() })}
301 class="group relative flex w-full items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent px-2 transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40"
302 >
303 <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7">
304 <span class="iconify lucide--folder-open shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" />
305 <span class="truncate py-0.5 font-medium text-blue-500 transition-colors duration-150 group-hover:text-blue-600 dark:text-blue-400 dark:group-hover:text-blue-300">
306 {collection().name}
307 </span>
308 </div>
309 </button>
310 </Show>
311 )}
312 </Show>
313
314 {/* Record Level */}
315 <Show
316 when={(() => {
317 const v = props.view();
318 return v.type === "record" ? v.record : null;
319 })()}
320 >
321 {(record) => {
322 const rkeyTimestamp = createMemo(() => {
323 if (!record().key || !TID.validate(record().key)) return undefined;
324 const timestamp = TID.parse(record().key).timestamp / 1000;
325 return timestamp <= Date.now() ? timestamp : undefined;
326 });
327
328 return (
329 <div class="group relative flex items-center justify-between gap-1 rounded-md border-[0.5px] border-transparent bg-transparent px-2 transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40">
330 <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7">
331 <span class="iconify lucide--file-json shrink-0 text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200" />
332 <div class="flex min-w-0 gap-1 py-0.5 font-medium">
333 <span class="shrink-0">{record().key}</span>
334 <Show when={rkeyTimestamp()}>
335 <span class="truncate text-neutral-500 dark:text-neutral-400">
336 ({localDateFromTimestamp(rkeyTimestamp()!)})
337 </span>
338 </Show>
339 </div>
340 </div>
341 </div>
342 );
343 }}
344 </Show>
345 </nav>
346
347 <div class="px-2 py-2">
348 <Switch>
349 <Match when={props.view().type === "repo"}>
350 <RepoSubview archive={props.archive} onRoute={props.setView} />
351 </Match>
352
353 <Match
354 when={(() => {
355 const v = props.view();
356 return v.type === "collection" ? v : null;
357 })()}
358 keyed
359 >
360 {({ collection }) => (
361 <CollectionSubview
362 archive={props.archive}
363 collection={collection}
364 onRoute={props.setView}
365 />
366 )}
367 </Match>
368
369 <Match
370 when={(() => {
371 const v = props.view();
372 return v.type === "record" ? v : null;
373 })()}
374 keyed
375 >
376 {({ collection, record }) => (
377 <RecordSubview archive={props.archive} collection={collection} record={record} />
378 )}
379 </Match>
380 </Switch>
381 </div>
382 </div>
383 );
384};
385
386const RepoSubview = (props: { archive: Archive; onRoute: (view: View) => void }) => {
387 const [filter, setFilter] = createSignal("");
388
389 const sortedEntries = createMemo(() => {
390 return [...props.archive.entries].sort((a, b) => a.name.localeCompare(b.name));
391 });
392
393 const filteredEntries = createMemo(() => {
394 const f = filter().toLowerCase().trim();
395 if (!f) return sortedEntries();
396 return sortedEntries().filter((entry) => entry.name.toLowerCase().includes(f));
397 });
398
399 const totalRecords = createMemo(() =>
400 props.archive.entries.reduce((sum, entry) => sum + entry.entries.length, 0),
401 );
402
403 return (
404 <div class="flex flex-col gap-3">
405 <div class="text-sm text-neutral-600 dark:text-neutral-400">
406 {props.archive.entries.length} collection{props.archive.entries.length !== 1 ? "s" : ""}
407 <span class="text-neutral-400 dark:text-neutral-600"> · </span>
408 {totalRecords()} record{totalRecords() !== 1 ? "s" : ""}
409 </div>
410
411 <TextInput
412 placeholder="Filter collections"
413 value={filter()}
414 onInput={(e) => setFilter(e.currentTarget.value)}
415 class="text-sm"
416 />
417
418 <ul class="flex flex-col">
419 <For each={filteredEntries()}>
420 {(entry) => {
421 const hasSingleEntry = entry.entries.length === 1;
422 const authority = () => entry.name.split(".").slice(0, 2).join(".");
423
424 return (
425 <li>
426 <button
427 onClick={() => {
428 if (hasSingleEntry) {
429 props.onRoute({
430 type: "record",
431 collection: entry,
432 record: entry.entries[0],
433 });
434 } else {
435 props.onRoute({ type: "collection", collection: entry });
436 }
437 }}
438 class="flex w-full items-center gap-2 rounded p-2 text-left text-sm hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-800 dark:active:bg-neutral-700"
439 >
440 <Favicon authority={authority()} />
441 <span
442 class="truncate font-medium"
443 classList={{
444 "text-neutral-700 dark:text-neutral-300": hasSingleEntry,
445 "text-blue-500 dark:text-blue-400": !hasSingleEntry,
446 }}
447 >
448 {entry.name}
449 </span>
450
451 <Show when={hasSingleEntry}>
452 <span class="iconify lucide--chevron-right shrink-0 text-xs text-neutral-500" />
453 <span class="truncate font-medium text-blue-500 dark:text-blue-400">
454 {entry.entries[0].key}
455 </span>
456 </Show>
457
458 <Show when={!hasSingleEntry}>
459 <span class="ml-auto text-xs text-neutral-500">{entry.entries.length}</span>
460 </Show>
461 </button>
462 </li>
463 );
464 }}
465 </For>
466 </ul>
467
468 <Show when={filteredEntries().length === 0 && filter()}>
469 <div class="flex flex-col items-center justify-center py-8 text-center">
470 <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" />
471 <p class="text-sm text-neutral-600 dark:text-neutral-400">
472 No collections match your filter
473 </p>
474 </div>
475 </Show>
476 </div>
477 );
478};
479
480const RECORDS_PER_PAGE = 100;
481
482const CollectionSubview = (props: {
483 archive: Archive;
484 collection: CollectionEntry;
485 onRoute: (view: View) => void;
486}) => {
487 const [filter, setFilter] = createSignal("");
488 const debouncedFilter = createDebouncedValue(filter, 150);
489 const [displayCount, setDisplayCount] = createSignal(RECORDS_PER_PAGE);
490
491 // Sort entries by TID timestamp (most recent first), non-TID entries go to the end
492 const sortedEntries = createMemo(() => {
493 return [...props.collection.entries].sort((a, b) => {
494 const aIsTid = TID.validate(a.key);
495 const bIsTid = TID.validate(b.key);
496
497 if (aIsTid && bIsTid) {
498 return TID.parse(b.key).timestamp - TID.parse(a.key).timestamp;
499 }
500 if (aIsTid) return -1;
501 if (bIsTid) return 1;
502 return b.key.localeCompare(a.key);
503 });
504 });
505
506 const searchableEntries = createMemo(() => {
507 return sortedEntries().map((entry) => ({
508 entry,
509 searchText: JSON.stringify(entry.record).toLowerCase(),
510 }));
511 });
512
513 const filteredEntries = createMemo(() => {
514 const f = debouncedFilter().toLowerCase().trim();
515 if (!f) return sortedEntries();
516 return searchableEntries()
517 .filter(({ searchText }) => searchText.includes(f))
518 .map(({ entry }) => entry);
519 });
520
521 createEffect(() => {
522 debouncedFilter();
523 untrack(() => setDisplayCount(RECORDS_PER_PAGE));
524 });
525
526 const displayedEntries = createMemo(() => {
527 return filteredEntries().slice(0, displayCount());
528 });
529
530 const hasMore = createMemo(() => filteredEntries().length > displayCount());
531
532 const loadMore = () => {
533 setDisplayCount((prev) => prev + RECORDS_PER_PAGE);
534 };
535
536 return (
537 <div class="flex flex-col gap-3">
538 <span class="text-sm text-neutral-600 dark:text-neutral-400">
539 {filteredEntries().length} record{filteredEntries().length !== 1 ? "s" : ""}
540 {filter() && filteredEntries().length !== props.collection.entries.length && (
541 <span class="text-neutral-400 dark:text-neutral-500">
542 {" "}
543 (of {props.collection.entries.length})
544 </span>
545 )}
546 </span>
547
548 <div class="flex items-center gap-2">
549 <TextInput
550 placeholder="Filter records"
551 value={filter()}
552 onInput={(e) => setFilter(e.currentTarget.value)}
553 class="grow text-sm"
554 />
555
556 <Show when={hasMore()}>
557 <span class="text-sm text-neutral-600 dark:text-neutral-400">
558 {displayedEntries().length}/{filteredEntries().length}
559 </span>
560
561 <Button onClick={loadMore}>Load more</Button>
562 </Show>
563 </div>
564
565 <div class="flex flex-col font-mono">
566 <For each={displayedEntries()}>
567 {(entry) => {
568 const isTid = TID.validate(entry.key);
569 const timestamp = isTid ? TID.parse(entry.key).timestamp / 1_000 : null;
570
571 return (
572 <HoverCard
573 class="flex w-full items-baseline gap-1 rounded hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
574 trigger={
575 <button
576 onClick={() => {
577 props.onRoute({
578 type: "record",
579 collection: props.collection,
580 record: entry,
581 });
582 }}
583 class="flex w-full items-baseline gap-1 px-1 py-0.5"
584 >
585 <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400">
586 {entry.key}
587 </span>
588 <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl">
589 {entry.cid}
590 </span>
591 <Show when={timestamp}>
592 {(ts) => (
593 <span class="ml-auto shrink-0 text-xs">{localDateFromTimestamp(ts())}</span>
594 )}
595 </Show>
596 </button>
597 }
598 >
599 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs />
600 </HoverCard>
601 );
602 }}
603 </For>
604 </div>
605
606 <Show when={filteredEntries().length === 0 && filter()}>
607 <div class="flex flex-col items-center justify-center py-8 text-center">
608 <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" />
609 <p class="text-sm text-neutral-600 dark:text-neutral-400">No records match your filter</p>
610 </div>
611 </Show>
612 </div>
613 );
614};
615
616const RecordSubview = (props: {
617 archive: Archive;
618 collection: CollectionEntry;
619 record: RecordEntry;
620}) => {
621 return (
622 <div class="flex flex-col items-center gap-3">
623 <div class="flex w-full items-center gap-2 text-sm text-neutral-600 sm:text-base dark:text-neutral-400">
624 <span class="iconify lucide--box shrink-0" />
625 <span class="text-xs break-all">{props.record.cid}</span>
626 </div>
627
628 <Show
629 when={props.record.record !== null}
630 fallback={
631 <div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
632 Failed to decode record
633 </div>
634 }
635 >
636 <div class="max-w-full min-w-full font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:w-max sm:max-w-screen sm:px-4 sm:text-sm md:max-w-3xl">
637 <JSONValue data={props.record.record} repo={props.archive.did || ""} newTab hideBlobs />
638 </div>
639 </Show>
640 </div>
641 );
642};