atmosphere explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

car explorer

Juliet 2f01af89 0cb76dac

+681 -5
+3
package.json
··· 23 23 "dependencies": { 24 24 "@atcute/atproto": "^3.1.10", 25 25 "@atcute/bluesky": "^3.2.14", 26 + "@atcute/car": "^5.0.0", 27 + "@atcute/cbor": "^2.2.8", 28 + "@atcute/cid": "^2.3.0", 26 29 "@atcute/client": "^4.2.0", 27 30 "@atcute/crypto": "^2.3.0", 28 31 "@atcute/did-plc": "^0.3.1",
+9
pnpm-lock.yaml
··· 14 14 '@atcute/bluesky': 15 15 specifier: ^3.2.14 16 16 version: 3.2.14 17 + '@atcute/car': 18 + specifier: ^5.0.0 19 + version: 5.0.0 20 + '@atcute/cbor': 21 + specifier: ^2.2.8 22 + version: 2.2.8 23 + '@atcute/cid': 24 + specifier: ^2.3.0 25 + version: 2.3.0 17 26 '@atcute/client': 18 27 specifier: ^4.2.0 19 28 version: 4.2.0
+21 -5
src/components/json.tsx
··· 20 20 repo: string; 21 21 truncate?: boolean; 22 22 parentIsBlob?: boolean; 23 + newTab?: boolean; 23 24 } 24 25 25 26 const JSONCtx = createContext<JSONContext>(); ··· 53 54 const authority = await resolveLexiconAuthority(nsid as Nsid); 54 55 55 56 const hash = anchor ? `#schema:${anchor}` : "#schema"; 56 - navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 57 + if (ctx.newTab) 58 + window.open(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`, "_blank"); 59 + else navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 57 60 } catch (err) { 58 61 console.error("Failed to resolve lexicon authority:", err); 59 62 const id = addNotification({ ··· 76 79 {(part) => ( 77 80 <> 78 81 {isResourceUri(part) ? 79 - <A class="text-blue-400 hover:underline active:underline" href={`/${part}`}> 82 + <A 83 + class="text-blue-400 hover:underline active:underline" 84 + href={`/${part}`} 85 + target={ctx.newTab ? "_blank" : "_self"} 86 + > 80 87 {part} 81 88 </A> 82 89 : isDid(part) ? 83 - <A class="text-blue-400 hover:underline active:underline" href={`/at://${part}`}> 90 + <A 91 + class="text-blue-400 hover:underline active:underline" 92 + href={`/at://${part}`} 93 + target={ctx.newTab ? "_blank" : "_self"} 94 + > 84 95 {part} 85 96 </A> 86 97 : isNsid(part.split("#")[0]) && props.isType ? ··· 297 308 return <JSONObject data={data} />; 298 309 }; 299 310 300 - export const JSONValue = (props: { data: JSONType; repo: string; truncate?: boolean }) => { 311 + export const JSONValue = (props: { 312 + data: JSONType; 313 + repo: string; 314 + truncate?: boolean; 315 + newTab?: boolean; 316 + }) => { 301 317 return ( 302 - <JSONCtx.Provider value={{ repo: props.repo, truncate: props.truncate }}> 318 + <JSONCtx.Provider value={{ repo: props.repo, truncate: props.truncate, newTab: props.newTab }}> 303 319 <JSONValueInner data={props.data} /> 304 320 </JSONCtx.Provider> 305 321 );
+2
src/index.tsx
··· 3 3 import { render } from "solid-js/web"; 4 4 import { Layout } from "./layout.tsx"; 5 5 import "./styles/index.css"; 6 + import { CarView } from "./views/car.tsx"; 6 7 import { CollectionView } from "./views/collection.tsx"; 7 8 import { Home } from "./views/home.tsx"; 8 9 import { LabelView } from "./views/labels.tsx"; ··· 18 19 <Route path="/" component={Home} /> 19 20 <Route path={["/jetstream", "/firehose"]} component={StreamView} /> 20 21 <Route path="/labels" component={LabelView} /> 22 + <Route path="/car" component={CarView} /> 21 23 <Route path="/settings" component={Settings} /> 22 24 <Route path="/:pds" component={PdsView} /> 23 25 <Route path="/:pds/:repo" component={RepoView} />
+1
src/layout.tsx
··· 159 159 <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 160 160 <NavMenu href="/firehose" label="Firehose" icon="lucide--antenna" /> 161 161 <NavMenu href="/labels" label="Labels" icon="lucide--tag" /> 162 + <NavMenu href="/car" label="CAR explorer" icon="lucide--folder-archive" /> 162 163 <NavMenu href="/settings" label="Settings" icon="lucide--settings" /> 163 164 <MenuSeparator /> 164 165 <NavMenu
+645
src/views/car.tsx
··· 1 + import * as CAR from "@atcute/car"; 2 + import * as CBOR from "@atcute/cbor"; 3 + import * as CID from "@atcute/cid"; 4 + import { fromStream, isCommit } from "@atcute/repo"; 5 + import * as TID from "@atcute/tid"; 6 + import { Title } from "@solidjs/meta"; 7 + import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"; 8 + import { Button } from "../components/button.jsx"; 9 + import { JSONValue, type JSONType } from "../components/json.jsx"; 10 + import { TextInput } from "../components/text-input.jsx"; 11 + import { isTouchDevice } from "../layout.jsx"; 12 + import { localDateFromTimestamp } from "../utils/date.js"; 13 + 14 + // Convert CBOR-decoded objects to JSON-friendly format 15 + const toJsonValue = (obj: unknown): JSONType => { 16 + if (obj === null || obj === undefined) return null; 17 + 18 + if (CID.isCidLink(obj)) { 19 + return { $link: obj.$link }; 20 + } 21 + 22 + if ( 23 + obj && 24 + typeof obj === "object" && 25 + "version" in obj && 26 + "codec" in obj && 27 + "digest" in obj && 28 + "bytes" in obj 29 + ) { 30 + try { 31 + return { $link: CID.toString(obj as CID.Cid) }; 32 + } catch {} 33 + } 34 + 35 + if (CBOR.isBytes(obj)) { 36 + return { $bytes: obj.$bytes }; 37 + } 38 + 39 + if (Array.isArray(obj)) { 40 + return obj.map(toJsonValue); 41 + } 42 + 43 + if (typeof obj === "object") { 44 + const result: Record<string, JSONType> = {}; 45 + for (const [key, value] of Object.entries(obj)) { 46 + result[key] = toJsonValue(value); 47 + } 48 + return result; 49 + } 50 + 51 + return obj as JSONType; 52 + }; 53 + 54 + interface Archive { 55 + file: File; 56 + did: string; 57 + entries: CollectionEntry[]; 58 + } 59 + 60 + interface CollectionEntry { 61 + name: string; 62 + entries: RecordEntry[]; 63 + } 64 + 65 + interface RecordEntry { 66 + key: string; 67 + cid: string; 68 + record: JSONType; 69 + } 70 + 71 + type View = 72 + | { type: "repo" } 73 + | { type: "collection"; collection: CollectionEntry } 74 + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 75 + 76 + export const CarView = () => { 77 + const [archive, setArchive] = createSignal<Archive | null>(null); 78 + const [loading, setLoading] = createSignal(false); 79 + const [error, setError] = createSignal<string>(); 80 + const [view, setView] = createSignal<View>({ type: "repo" }); 81 + 82 + const parseCarFile = async (file: File) => { 83 + setLoading(true); 84 + setError(undefined); 85 + 86 + try { 87 + // Read file as ArrayBuffer to extract DID from commit block 88 + const buffer = new Uint8Array(await file.arrayBuffer()); 89 + const car = CAR.fromUint8Array(buffer); 90 + 91 + // Get DID from commit block 92 + let did = ""; 93 + const rootCid = car.roots[0]?.$link; 94 + if (rootCid) { 95 + for (const entry of car) { 96 + if (CID.toString(entry.cid) === rootCid) { 97 + const commit = CBOR.decode(entry.bytes); 98 + if (isCommit(commit)) { 99 + did = commit.did; 100 + } 101 + break; 102 + } 103 + } 104 + } 105 + 106 + // Now parse records using fromStream 107 + const stream = file.stream(); 108 + await using repo = fromStream(stream); 109 + 110 + const collections = new Map<string, RecordEntry[]>(); 111 + const result: Archive = { 112 + file, 113 + did, 114 + entries: [], 115 + }; 116 + 117 + for await (const entry of repo) { 118 + let list = collections.get(entry.collection); 119 + if (list === undefined) { 120 + collections.set(entry.collection, (list = [])); 121 + result.entries.push({ 122 + name: entry.collection, 123 + entries: list, 124 + }); 125 + } 126 + 127 + const record = toJsonValue(entry.record); 128 + list.push({ 129 + key: entry.rkey, 130 + cid: entry.cid.$link, 131 + record, 132 + }); 133 + } 134 + 135 + setArchive(result); 136 + setView({ type: "repo" }); 137 + } catch (err) { 138 + console.error("Failed to parse CAR file:", err); 139 + setError(err instanceof Error ? err.message : "Failed to parse CAR file"); 140 + } finally { 141 + setLoading(false); 142 + } 143 + }; 144 + 145 + const handleFileChange = (e: Event) => { 146 + const input = e.target as HTMLInputElement; 147 + const file = input.files?.[0]; 148 + if (file) { 149 + parseCarFile(file); 150 + } 151 + }; 152 + 153 + const handleDrop = (e: DragEvent) => { 154 + e.preventDefault(); 155 + const file = e.dataTransfer?.files?.[0]; 156 + if (file && (file.name.endsWith(".car") || file.type === "application/vnd.ipld.car")) { 157 + parseCarFile(file); 158 + } 159 + }; 160 + 161 + const handleDragOver = (e: DragEvent) => { 162 + e.preventDefault(); 163 + }; 164 + 165 + const reset = () => { 166 + setArchive(null); 167 + setView({ type: "repo" }); 168 + setError(undefined); 169 + }; 170 + 171 + return ( 172 + <> 173 + <Title>CAR explorer - PDSls</Title> 174 + <div class="flex w-full flex-col items-center"> 175 + <Show 176 + when={archive()} 177 + fallback={ 178 + <WelcomeView 179 + loading={loading()} 180 + error={error()} 181 + onFileChange={handleFileChange} 182 + onDrop={handleDrop} 183 + onDragOver={handleDragOver} 184 + /> 185 + } 186 + > 187 + {(arch) => <ExploreView archive={arch()} view={view} setView={setView} onClose={reset} />} 188 + </Show> 189 + </div> 190 + </> 191 + ); 192 + }; 193 + 194 + const WelcomeView = (props: { 195 + loading: boolean; 196 + error?: string; 197 + onFileChange: (e: Event) => void; 198 + onDrop: (e: DragEvent) => void; 199 + onDragOver: (e: DragEvent) => void; 200 + }) => { 201 + return ( 202 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 203 + <div class="flex flex-col gap-y-1"> 204 + <h1 class="text-lg font-semibold">CAR explorer</h1> 205 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 206 + Upload a CAR (Content Addressable aRchive) file to explore its contents. 207 + </p> 208 + </div> 209 + 210 + <div 211 + class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500" 212 + onDrop={props.onDrop} 213 + onDragOver={props.onDragOver} 214 + > 215 + <Show 216 + when={!props.loading} 217 + fallback={ 218 + <div class="flex flex-col items-center gap-2"> 219 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 220 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 221 + Reading CAR file... 222 + </span> 223 + </div> 224 + } 225 + > 226 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 227 + <div class="text-center"> 228 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 229 + Drag and drop a CAR file here 230 + </p> 231 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 232 + </div> 233 + <label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 234 + <input 235 + type="file" 236 + accept=".car,application/vnd.ipld.car" 237 + onChange={props.onFileChange} 238 + class="hidden" 239 + /> 240 + <span class="iconify lucide--upload text-sm" /> 241 + Choose file 242 + </label> 243 + </Show> 244 + </div> 245 + 246 + <Show when={props.error}> 247 + <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"> 248 + {props.error} 249 + </div> 250 + </Show> 251 + </div> 252 + ); 253 + }; 254 + 255 + const ExploreView = (props: { 256 + archive: Archive; 257 + view: () => View; 258 + setView: (view: View) => void; 259 + onClose: () => void; 260 + }) => { 261 + return ( 262 + <div class="flex w-full flex-col"> 263 + <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 264 + {/* DID / Repository Level */} 265 + <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"> 266 + <Show 267 + when={props.view().type !== "repo"} 268 + fallback={ 269 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7"> 270 + <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" /> 271 + <span class="truncate py-0.5 font-medium">{props.archive.did || "Repository"}</span> 272 + </div> 273 + } 274 + > 275 + <button 276 + type="button" 277 + onClick={() => props.setView({ type: "repo" })} 278 + class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7" 279 + > 280 + <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" /> 281 + <span class="truncate py-0.5 font-medium text-blue-400 transition-colors duration-150 group-hover:text-blue-500 dark:group-hover:text-blue-300"> 282 + {props.archive.did || "Repository"} 283 + </span> 284 + </button> 285 + </Show> 286 + <button 287 + type="button" 288 + onClick={props.onClose} 289 + title="Close and upload a different file" 290 + 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" 291 + > 292 + <span class="iconify lucide--x" /> 293 + </button> 294 + </div> 295 + 296 + {/* Collection Level */} 297 + <Show 298 + when={(() => { 299 + const v = props.view(); 300 + return v.type === "collection" || v.type === "record" ? v.collection : null; 301 + })()} 302 + > 303 + {(collection) => ( 304 + <Show 305 + when={props.view().type === "record"} 306 + fallback={ 307 + <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"> 308 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 309 + <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" /> 310 + <span class="truncate py-0.5 font-medium">{collection().name}</span> 311 + </div> 312 + </div> 313 + } 314 + > 315 + <button 316 + type="button" 317 + onClick={() => props.setView({ type: "collection", collection: collection() })} 318 + 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" 319 + > 320 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 321 + <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" /> 322 + <span class="truncate py-0.5 font-medium text-blue-400 transition-colors duration-150 group-hover:text-blue-500 dark:group-hover:text-blue-300"> 323 + {collection().name} 324 + </span> 325 + </div> 326 + </button> 327 + </Show> 328 + )} 329 + </Show> 330 + 331 + {/* Record Level */} 332 + <Show 333 + when={(() => { 334 + const v = props.view(); 335 + return v.type === "record" ? v.record : null; 336 + })()} 337 + > 338 + {(record) => ( 339 + <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"> 340 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 341 + <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" /> 342 + <span class="truncate py-0.5 font-medium">{record().key}</span> 343 + </div> 344 + </div> 345 + )} 346 + </Show> 347 + </nav> 348 + 349 + <div class="px-2 py-2"> 350 + <Switch> 351 + <Match when={props.view().type === "repo"}> 352 + <RepoSubview archive={props.archive} onRoute={props.setView} /> 353 + </Match> 354 + 355 + <Match 356 + when={(() => { 357 + const v = props.view(); 358 + return v.type === "collection" ? v : null; 359 + })()} 360 + keyed 361 + > 362 + {({ collection }) => ( 363 + <CollectionSubview 364 + archive={props.archive} 365 + collection={collection} 366 + onRoute={props.setView} 367 + /> 368 + )} 369 + </Match> 370 + 371 + <Match 372 + when={(() => { 373 + const v = props.view(); 374 + return v.type === "record" ? v : null; 375 + })()} 376 + keyed 377 + > 378 + {({ collection, record }) => ( 379 + <RecordSubview archive={props.archive} collection={collection} record={record} /> 380 + )} 381 + </Match> 382 + </Switch> 383 + </div> 384 + </div> 385 + ); 386 + }; 387 + 388 + const RepoSubview = (props: { archive: Archive; onRoute: (view: View) => void }) => { 389 + const [filter, setFilter] = createSignal(""); 390 + 391 + const sortedEntries = createMemo(() => { 392 + return [...props.archive.entries].sort((a, b) => a.name.localeCompare(b.name)); 393 + }); 394 + 395 + const filteredEntries = createMemo(() => { 396 + const f = filter().toLowerCase().trim(); 397 + if (!f) return sortedEntries(); 398 + return sortedEntries().filter((entry) => entry.name.toLowerCase().includes(f)); 399 + }); 400 + 401 + const totalRecords = createMemo(() => 402 + props.archive.entries.reduce((sum, entry) => sum + entry.entries.length, 0), 403 + ); 404 + 405 + return ( 406 + <div class="flex flex-col gap-3"> 407 + <div class="text-sm text-neutral-600 dark:text-neutral-400"> 408 + {props.archive.entries.length} collection{props.archive.entries.length > 1 ? "s" : ""} 409 + <span class="text-neutral-400 dark:text-neutral-600"> · </span> 410 + {totalRecords()} record{totalRecords() > 1 ? "s" : ""} 411 + </div> 412 + 413 + <TextInput 414 + placeholder="Filter collections" 415 + value={filter()} 416 + onInput={(e) => setFilter(e.currentTarget.value)} 417 + class="text-sm" 418 + /> 419 + 420 + <ul class="flex flex-col"> 421 + <For each={filteredEntries()}> 422 + {(entry) => { 423 + const hasSingleEntry = entry.entries.length === 1; 424 + 425 + return ( 426 + <li> 427 + <button 428 + onClick={() => { 429 + if (hasSingleEntry) { 430 + props.onRoute({ 431 + type: "record", 432 + collection: entry, 433 + record: entry.entries[0], 434 + }); 435 + } else { 436 + props.onRoute({ type: "collection", collection: entry }); 437 + } 438 + }} 439 + class="flex w-full items-center gap-2 rounded p-2 text-left text-sm hover:bg-neutral-200 dark:hover:bg-neutral-800" 440 + > 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 + 480 + const RECORDS_PER_PAGE = 100; 481 + 482 + const CollectionSubview = (props: { 483 + archive: Archive; 484 + collection: CollectionEntry; 485 + onRoute: (view: View) => void; 486 + }) => { 487 + const [filter, setFilter] = createSignal(""); 488 + const [displayCount, setDisplayCount] = createSignal(RECORDS_PER_PAGE); 489 + 490 + // Sort entries by TID timestamp (most recent first), non-TID entries go to the end 491 + const sortedEntries = createMemo(() => { 492 + return [...props.collection.entries].sort((a, b) => { 493 + const aIsTid = TID.validate(a.key); 494 + const bIsTid = TID.validate(b.key); 495 + 496 + if (aIsTid && bIsTid) { 497 + return TID.parse(b.key).timestamp - TID.parse(a.key).timestamp; 498 + } 499 + if (aIsTid) return -1; 500 + if (bIsTid) return 1; 501 + return b.key.localeCompare(a.key); 502 + }); 503 + }); 504 + 505 + const filteredEntries = createMemo(() => { 506 + const f = filter().toLowerCase().trim(); 507 + if (!f) return sortedEntries(); 508 + return sortedEntries().filter((entry) => 509 + JSON.stringify(entry.record).toLowerCase().includes(f), 510 + ); 511 + }); 512 + 513 + const displayedEntries = createMemo(() => { 514 + return filteredEntries().slice(0, displayCount()); 515 + }); 516 + 517 + const hasMore = createMemo(() => filteredEntries().length > displayCount()); 518 + 519 + const loadMore = () => { 520 + setDisplayCount((prev) => prev + RECORDS_PER_PAGE); 521 + }; 522 + 523 + return ( 524 + <div class="flex flex-col gap-3"> 525 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 526 + {filteredEntries().length} record{filteredEntries().length > 1 ? "s" : ""} 527 + {filter() && filteredEntries().length !== props.collection.entries.length && ( 528 + <span class="text-neutral-400 dark:text-neutral-500"> 529 + {" "} 530 + (of {props.collection.entries.length}) 531 + </span> 532 + )} 533 + </span> 534 + 535 + <div class="flex items-center gap-2"> 536 + <TextInput 537 + placeholder="Filter records" 538 + value={filter()} 539 + onInput={(e) => { 540 + setFilter(e.currentTarget.value); 541 + setDisplayCount(RECORDS_PER_PAGE); 542 + }} 543 + class="grow text-sm" 544 + /> 545 + 546 + <Show when={hasMore()}> 547 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 548 + {displayedEntries().length}/{filteredEntries().length} 549 + </span> 550 + 551 + <Button onClick={loadMore}>Load More</Button> 552 + </Show> 553 + </div> 554 + 555 + <div class="flex flex-col font-mono"> 556 + <For each={displayedEntries()}> 557 + {(entry) => { 558 + const isTid = TID.validate(entry.key); 559 + const timestamp = isTid ? TID.parse(entry.key).timestamp / 1_000 : null; 560 + const [hover, setHover] = createSignal(false); 561 + const [previewHeight, setPreviewHeight] = createSignal(0); 562 + let rkeyRef!: HTMLButtonElement; 563 + let previewRef!: HTMLSpanElement; 564 + 565 + createEffect(() => { 566 + if (hover()) setPreviewHeight(previewRef.offsetHeight); 567 + }); 568 + 569 + const isOverflowing = (previewHeight: number) => 570 + rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; 571 + 572 + return ( 573 + <button 574 + onClick={() => { 575 + props.onRoute({ 576 + type: "record", 577 + collection: props.collection, 578 + record: entry, 579 + }); 580 + }} 581 + ref={rkeyRef} 582 + onmouseover={() => !isTouchDevice && setHover(true)} 583 + onmouseleave={() => !isTouchDevice && setHover(false)} 584 + class="relative flex w-full items-baseline gap-1 rounded px-1 py-0.5 text-left hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 585 + > 586 + <span class="shrink-0 text-sm text-blue-400">{entry.key}</span> 587 + <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 588 + {entry.cid} 589 + </span> 590 + <Show when={timestamp}> 591 + {(ts) => ( 592 + <span class="ml-auto shrink-0 text-xs">{localDateFromTimestamp(ts())}</span> 593 + )} 594 + </Show> 595 + <Show when={hover()}> 596 + <span 597 + ref={previewRef} 598 + class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none absolute left-[50%] z-25 block max-h-80 w-max max-w-sm -translate-x-1/2 overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-xs whitespace-pre-wrap shadow-md sm:max-h-112 lg:max-w-lg dark:border-neutral-700 ${isOverflowing(previewHeight()) ? "bottom-7" : "top-7"}`} 599 + > 600 + <JSONValue data={entry.record} repo={props.archive.did} truncate /> 601 + </span> 602 + </Show> 603 + </button> 604 + ); 605 + }} 606 + </For> 607 + </div> 608 + 609 + <Show when={filteredEntries().length === 0 && filter()}> 610 + <div class="flex flex-col items-center justify-center py-8 text-center"> 611 + <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" /> 612 + <p class="text-sm text-neutral-600 dark:text-neutral-400">No records match your filter</p> 613 + </div> 614 + </Show> 615 + </div> 616 + ); 617 + }; 618 + 619 + const RecordSubview = (props: { 620 + archive: Archive; 621 + collection: CollectionEntry; 622 + record: RecordEntry; 623 + }) => { 624 + return ( 625 + <div class="flex flex-col items-center gap-3"> 626 + <div class="flex w-full items-center gap-2 text-sm text-neutral-600 sm:text-base dark:text-neutral-400"> 627 + <span class="iconify lucide--box shrink-0" /> 628 + <span class="text-xs break-all">{props.record.cid}</span> 629 + </div> 630 + 631 + <Show 632 + when={props.record.record !== null} 633 + fallback={ 634 + <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"> 635 + Failed to decode record 636 + </div> 637 + } 638 + > 639 + <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"> 640 + <JSONValue data={props.record.record} repo={props.archive.did || ""} newTab /> 641 + </div> 642 + </Show> 643 + </div> 644 + ); 645 + };