atmosphere explorer
at main 642 lines 24 kB view raw
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};