atmosphere explorer pds.ls
tool typescript atproto

Compare changes

Choose any two refs to compare.

+5005 -1306
+70 -10
src/components/create.tsx
··· 1 1 2 2 3 3 4 + import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 + import { remove } from "@mary/exif-rm"; 6 + import { useNavigate, useParams } from "@solidjs/router"; 7 + import { 8 + createEffect, 9 + createSignal, 10 + For, 11 + lazy, 12 + onCleanup, 13 + onMount, 14 + Show, 15 + Suspense, 16 + } from "solid-js"; 17 + import { hasUserScope } from "../auth/scope-utils"; 18 + import { agent, sessions } from "../auth/state"; 19 + import { Button } from "./button.jsx"; 4 20 5 21 6 22 ··· 36 52 37 53 38 54 55 + } 56 + }); 39 57 58 + onMount(() => { 59 + const keyEvent = (ev: KeyboardEvent) => { 60 + if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; 61 + if ((ev.target as HTMLElement).closest("[data-modal]")) return; 40 62 63 + const key = props.create ? "n" : "e"; 64 + if (ev.key === key) { 65 + ev.preventDefault(); 41 66 67 + if (openDialog() && isMinimized()) { 68 + setIsMinimized(false); 69 + } else if (!openDialog() && !document.querySelector("[data-modal]")) { 70 + setOpenDialog(true); 71 + } 72 + } 73 + }; 42 74 75 + window.addEventListener("keydown", keyEvent); 76 + onCleanup(() => window.removeEventListener("keydown", keyEvent)); 77 + }); 43 78 79 + const defaultPlaceholder = () => { 80 + return { 81 + $type: "app.bsky.feed.post", 44 82 45 83 46 84 ··· 142 180 143 181 144 182 183 + return; 184 + } 185 + } else { 186 + const res = await rpc.post("com.atproto.repo.applyWrites", { 187 + input: { 188 + repo: agent()!.sub, 189 + validate: validate(), 190 + writes: [ 191 + { 192 + collection: params.collection as `${string}.${string}.${string}`, 193 + rkey: params.rkey!, 194 + $type: "com.atproto.repo.applyWrites#update", 195 + value: editedRecord, 196 + }, 197 + ], 198 + }, 199 + }); 200 + if (!res.ok) { 145 201 146 202 147 203 ··· 463 519 464 520 465 521 522 + </Tooltip> 523 + <span>Validate</span> 524 + </button> 525 + <Show when={!props.create && hasUserScope("create") && hasUserScope("delete")}> 526 + <Button onClick={() => editRecord(true)}>Recreate</Button> 527 + </Show> 528 + <Button 466 529 467 530 468 531 ··· 481 544 482 545 483 546 484 - 485 - 486 - 487 - </Tooltip> 488 - <span>Validate</span> 489 - </button> 490 - <Show when={!props.create}> 491 - <Button onClick={() => editRecord(true)}>Recreate</Button> 492 - </Show> 493 - <Button 547 + <span class="text-sm font-medium">{props.create ? "Creating" : "Editing"} record</span> 548 + </button> 549 + </Show> 550 + <Tooltip text={props.create ? "Create record (n)" : "Edit record (e)"}> 551 + <button 552 + class={`flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`} 553 + onclick={() => {
+2 -2
src/auth/session-manager.ts
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 2 import { Did } from "@atcute/lexicons"; 3 3 import { 4 4 finalizeAuthorization, ··· 20 20 21 21 export const getAvatar = async (did: Did): Promise<string | undefined> => { 22 22 const rpc = new Client({ 23 - handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 23 + handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 24 24 }); 25 25 const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } }); 26 26 if (res.ok) {
public/favicon.ico

This is a binary file and will not be displayed.

+1 -4
index.html
··· 7 7 8 8 9 9 10 - 10 + <meta property="og:description" content="Browse the public data on atproto" /> 11 11 <meta property="description" content="Browse the public data on atproto" /> 12 12 <link rel="manifest" href="/manifest.json" /> 13 - <title>PDSls</title> 14 - <link rel="preconnect" href="https://rsms.me/" /> 15 - <link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> 16 13 <link rel="preconnect" href="https://fonts.bunny.net" /> 17 14 <link href="https://fonts.bunny.net/css?family=roboto-mono:400" rel="stylesheet" /> 18 15 <link href="https://fonts.cdnfonts.com/css/pecita" rel="stylesheet" />
public/fonts/Figtree[wght].woff2

This is a binary file and will not be displayed.

+104
src/components/create/file-upload.tsx
··· 1 + import { Client } from "@atcute/client"; 2 + import { remove } from "@mary/exif-rm"; 3 + import { createSignal, onCleanup, Show } from "solid-js"; 4 + import { agent } from "../../auth/state"; 5 + import { formatFileSize } from "../../utils/format"; 6 + import { Button } from "../button.jsx"; 7 + import { TextInput } from "../text-input.jsx"; 8 + import { editorInstance } from "./state"; 9 + 10 + export const FileUpload = (props: { 11 + file: File; 12 + blobInput: HTMLInputElement; 13 + onClose: () => void; 14 + }) => { 15 + const [uploading, setUploading] = createSignal(false); 16 + const [error, setError] = createSignal(""); 17 + 18 + onCleanup(() => (props.blobInput.value = "")); 19 + 20 + const uploadBlob = async () => { 21 + let blob: Blob; 22 + 23 + const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 24 + (document.getElementById("mimetype") as HTMLInputElement).value = ""; 25 + if (mimetype) blob = new Blob([props.file], { type: mimetype }); 26 + else blob = props.file; 27 + 28 + if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 29 + const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 30 + if (exifRemoved !== null) blob = new Blob([exifRemoved as BlobPart], { type: blob.type }); 31 + } 32 + 33 + const rpc = new Client({ handler: agent()! }); 34 + setUploading(true); 35 + const res = await rpc.post("com.atproto.repo.uploadBlob", { 36 + input: blob, 37 + }); 38 + setUploading(false); 39 + if (!res.ok) { 40 + setError(res.data.error); 41 + return; 42 + } 43 + editorInstance.view.dispatch({ 44 + changes: { 45 + from: editorInstance.view.state.selection.main.head, 46 + insert: JSON.stringify(res.data.blob, null, 2), 47 + }, 48 + }); 49 + props.onClose(); 50 + }; 51 + 52 + return ( 53 + <> 54 + <h2 class="mb-2 font-semibold">Upload blob</h2> 55 + <div class="flex flex-col gap-2 text-sm"> 56 + <div class="flex flex-col gap-1"> 57 + <p class="flex gap-1"> 58 + <span class="truncate">{props.file.name}</span> 59 + <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 60 + ({formatFileSize(props.file.size)}) 61 + </span> 62 + </p> 63 + </div> 64 + <div class="flex items-center gap-x-2"> 65 + <label for="mimetype" class="shrink-0 select-none"> 66 + MIME type 67 + </label> 68 + <TextInput id="mimetype" placeholder={props.file.type} /> 69 + </div> 70 + <div class="flex items-center gap-1"> 71 + <input id="exif-rm" type="checkbox" checked /> 72 + <label for="exif-rm" class="select-none"> 73 + Remove EXIF data 74 + </label> 75 + </div> 76 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 77 + Metadata will be pasted after the cursor 78 + </p> 79 + <Show when={error()}> 80 + <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 81 + </Show> 82 + <div class="flex justify-between gap-2"> 83 + <Button onClick={props.onClose}>Cancel</Button> 84 + <Show when={uploading()}> 85 + <div class="flex items-center gap-1"> 86 + <span class="iconify lucide--loader-circle animate-spin"></span> 87 + <span>Uploading</span> 88 + </div> 89 + </Show> 90 + <Show when={!uploading()}> 91 + <Button 92 + onClick={uploadBlob} 93 + classList={{ 94 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 95 + }} 96 + > 97 + Upload 98 + </Button> 99 + </Show> 100 + </div> 101 + </div> 102 + </> 103 + ); 104 + };
+89
src/components/create/handle-input.tsx
··· 1 + import { Handle } from "@atcute/lexicons"; 2 + import { createSignal, Show } from "solid-js"; 3 + import { resolveHandle } from "../../utils/api"; 4 + import { Button } from "../button.jsx"; 5 + import { TextInput } from "../text-input.jsx"; 6 + import { editorInstance } from "./state"; 7 + 8 + export const HandleInput = (props: { onClose: () => void }) => { 9 + const [resolving, setResolving] = createSignal(false); 10 + const [error, setError] = createSignal(""); 11 + let handleFormRef!: HTMLFormElement; 12 + 13 + const resolveDid = async (e: SubmitEvent) => { 14 + e.preventDefault(); 15 + const formData = new FormData(handleFormRef); 16 + const handleValue = formData.get("handle")?.toString().trim(); 17 + 18 + if (!handleValue) { 19 + setError("Please enter a handle"); 20 + return; 21 + } 22 + 23 + setResolving(true); 24 + setError(""); 25 + try { 26 + const did = await resolveHandle(handleValue as Handle); 27 + editorInstance.view.dispatch({ 28 + changes: { 29 + from: editorInstance.view.state.selection.main.head, 30 + insert: `"${did}"`, 31 + }, 32 + }); 33 + props.onClose(); 34 + handleFormRef.reset(); 35 + } catch (err: any) { 36 + setError(err.message || "Failed to resolve handle"); 37 + } finally { 38 + setResolving(false); 39 + } 40 + }; 41 + 42 + return ( 43 + <> 44 + <h2 class="mb-2 font-semibold">Insert DID from handle</h2> 45 + <form ref={handleFormRef} onSubmit={resolveDid} class="flex flex-col gap-2 text-sm"> 46 + <div class="flex flex-col gap-1"> 47 + <label for="handle-input" class="select-none"> 48 + Handle 49 + </label> 50 + <TextInput id="handle-input" name="handle" placeholder="user.bsky.social" /> 51 + </div> 52 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 53 + DID will be pasted after the cursor 54 + </p> 55 + <Show when={error()}> 56 + <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 57 + </Show> 58 + <div class="flex justify-between gap-2"> 59 + <Button 60 + type="button" 61 + onClick={() => { 62 + props.onClose(); 63 + handleFormRef.reset(); 64 + setError(""); 65 + }} 66 + > 67 + Cancel 68 + </Button> 69 + <Show when={resolving()}> 70 + <div class="flex items-center gap-1"> 71 + <span class="iconify lucide--loader-circle animate-spin"></span> 72 + <span>Resolving</span> 73 + </div> 74 + </Show> 75 + <Show when={!resolving()}> 76 + <Button 77 + type="submit" 78 + classList={{ 79 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 80 + }} 81 + > 82 + Insert 83 + </Button> 84 + </Show> 85 + </div> 86 + </form> 87 + </> 88 + ); 89 + };
+12
src/components/create/menu-item.tsx
··· 1 + export const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => { 2 + return ( 3 + <button 4 + type="button" 5 + class="flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 6 + onClick={props.onClick} 7 + > 8 + <span class={`iconify ${props.icon}`}></span> 9 + <span>{props.label}</span> 10 + </button> 11 + ); 12 + };
+4
src/components/create/state.ts
··· 1 + import { createSignal } from "solid-js"; 2 + 3 + export const editorInstance = { view: null as any }; 4 + export const [placeholder, setPlaceholder] = createSignal<any>();
+42 -1
src/components/editor.tsx
··· 7 7 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 8 import { basicSetup, EditorView } from "codemirror"; 9 9 import { onCleanup, onMount } from "solid-js"; 10 - import { editorInstance } from "./create"; 10 + import { editorInstance } from "./create/state"; 11 11 12 12 const Editor = (props: { content: string }) => { 13 13 let editorDiv!: HTMLDivElement; 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + keymap.of([indentWithTab]), 49 + linter(jsonParseLinter()), 50 + themeColor.of(document.documentElement.classList.contains("dark") ? basicDark : basicLight), 51 + EditorView.lineWrapping, 52 + ], 53 + }); 54 + editorInstance.view = view;
+97
src/components/create/confirm-submit.tsx
··· 1 + import { createSignal, Show } from "solid-js"; 2 + import { hasUserScope } from "../../auth/scope-utils"; 3 + import { Button } from "../button.jsx"; 4 + 5 + export const ConfirmSubmit = (props: { 6 + isCreate: boolean; 7 + onConfirm: (validate: boolean | undefined, recreate: boolean) => void; 8 + onClose: () => void; 9 + }) => { 10 + const [validate, setValidate] = createSignal<boolean | undefined>(undefined); 11 + const [recreate, setRecreate] = createSignal(false); 12 + 13 + const getValidateLabel = () => { 14 + return ( 15 + validate() === true ? "True" 16 + : validate() === false ? "False" 17 + : "Unset" 18 + ); 19 + }; 20 + 21 + const cycleValidate = () => { 22 + setValidate( 23 + validate() === undefined ? true 24 + : validate() === true ? false 25 + : undefined, 26 + ); 27 + }; 28 + 29 + return ( 30 + <> 31 + <div class="flex flex-col gap-3 text-sm"> 32 + <h2 class="font-semibold">{props.isCreate ? "Create" : "Edit"} record</h2> 33 + <div class="flex flex-col gap-1.5"> 34 + <div class="flex items-center gap-2"> 35 + <button 36 + type="button" 37 + class="-ml-2 flex min-w-30 items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200/50 dark:hover:bg-neutral-700" 38 + onClick={cycleValidate} 39 + > 40 + <span 41 + classList={{ 42 + iconify: true, 43 + "lucide--square-check text-green-500 dark:text-green-400": validate() === true, 44 + "lucide--square-x text-red-500 dark:text-red-400": validate() === false, 45 + "lucide--square text-neutral-500 dark:text-neutral-400": validate() === undefined, 46 + }} 47 + ></span> 48 + <span>Validate: {getValidateLabel()}</span> 49 + </button> 50 + </div> 51 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 52 + Set to 'false' to skip lexicon schema validation by the PDS, 'true' to require it, or 53 + leave unset to validate only for known lexicons. 54 + </p> 55 + </div> 56 + <Show when={!props.isCreate}> 57 + <div class="flex flex-col gap-1.5"> 58 + <div class="flex items-center gap-2"> 59 + <button 60 + type="button" 61 + class={ 62 + hasUserScope("create") ? 63 + "-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs hover:bg-neutral-200/50 dark:hover:bg-neutral-700" 64 + : "-ml-2 flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs opacity-40" 65 + } 66 + onClick={() => hasUserScope("create") && setRecreate(!recreate())} 67 + > 68 + <span 69 + classList={{ 70 + iconify: true, 71 + "lucide--square-check text-green-500 dark:text-green-400": recreate(), 72 + "lucide--square text-neutral-500 dark:text-neutral-400": !recreate(), 73 + }} 74 + ></span> 75 + <span>Recreate{hasUserScope("create") ? "" : " (create permission needed)"}</span> 76 + </button> 77 + </div> 78 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 79 + Delete the existing record and create a new one with the same record key. 80 + </p> 81 + </div> 82 + </Show> 83 + <div class="flex justify-between gap-2"> 84 + <Button onClick={props.onClose}>Cancel</Button> 85 + <Button 86 + onClick={() => props.onConfirm(validate(), recreate())} 87 + classList={{ 88 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 89 + }} 90 + > 91 + {props.isCreate ? "Create" : "Edit"} 92 + </Button> 93 + </div> 94 + </div> 95 + </> 96 + ); 97 + };
+44 -50
src/views/logs.tsx
··· 5 5 6 6 7 7 8 + import { createEffect, createResource, createSignal, For, Show } from "solid-js"; 9 + import { localDateFromTimestamp } from "../utils/date.js"; 10 + import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 11 + import { plcDirectory } from "./settings.jsx"; 8 12 13 + type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; 9 14 10 15 11 16 ··· 16 21 17 22 18 23 24 + !activePlcEvent() || diffs.some((d) => d.type.startsWith(activePlcEvent()!)); 19 25 26 + const fetchPlcLogs = async () => { 27 + const res = await fetch(`${plcDirectory()}/${props.did}/log/audit`); 28 + const json = await res.json(); 29 + const logs = defs.indexedEntryLog.parse(json); 30 + setRawLogs(logs); 20 31 21 32 22 33 ··· 40 51 41 52 42 53 54 + } 55 + }); 43 56 57 + const FilterButton = (props: { event: PlcEvent; label: string }) => { 58 + const isActive = () => activePlcEvent() === props.event; 59 + const toggleFilter = () => setActivePlcEvent(isActive() ? undefined : props.event); 44 60 61 + return ( 62 + <button 63 + classList={{ 64 + "font-medium rounded-lg px-2 py-1.5 text-xs sm:text-sm transition-colors": true, 65 + "bg-neutral-700 text-white dark:bg-neutral-300 dark:text-neutral-900": isActive(), 66 + "bg-neutral-200 text-neutral-700 hover:bg-neutral-300 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600": 67 + !isActive(), 68 + }} 69 + onclick={toggleFilter} 70 + > 71 + {props.label} 72 + </button> 73 + ); 74 + }; 45 75 46 76 47 77 ··· 220 250 221 251 222 252 223 - 224 - 225 - 226 - 227 - 228 - 229 - 230 - 231 - 232 - 233 - 234 - 235 - 236 - 237 - 238 - 239 - 240 - 241 - 242 - 243 - 244 - 245 - 246 - 247 - 248 - 249 - 250 - 251 - 252 - 253 - 254 - 255 - 256 - 257 - 258 - 259 - 260 - 261 - 262 - 263 - 264 - 265 - 266 - 267 - 268 - 269 - 270 - 271 - 253 + <div class="iconify lucide--filter" /> 254 + <p class="font-medium">Filter by type</p> 255 + </div> 256 + <div class="flex flex-wrap gap-1"> 257 + <FilterButton event="handle" label="Alias" /> 258 + <FilterButton event="service" label="Service" /> 259 + <FilterButton event="verification_method" label="Verification" /> 260 + <FilterButton event="rotation_key" label="Rotation Key" /> 261 + </div> 272 262 </div> 273 263 <div class="flex items-center gap-1.5 text-sm font-medium"> 274 264 <Show when={validLog() === true}> 275 - <span class="iconify lucide--check-circle-2 text-green-500 dark:text-green-400"></span> 265 + <span class="iconify lucide--check text-green-600 dark:text-green-400"></span> 276 266 <span>Valid log</span> 277 267 </Show> 278 268 <Show when={validLog() === false}> 269 + <span class="iconify lucide--x text-red-500 dark:text-red-400"></span> 270 + <span>Log validation failed</span> 271 + </Show> 272 + <Show when={validLog() === undefined}>
+219 -33
src/views/home.tsx
··· 1 - export const Home = () => { 2 - return ( 3 - <div class="flex w-full flex-col gap-3 wrap-break-word"> 4 - <div class="flex flex-col gap-0.5"> 5 - <div> 6 - <span class="text-xl font-semibold">AT Protocol Explorer</span> 7 - </div> 8 - 9 - 10 - 11 - 1 + import { A } from "@solidjs/router"; 2 + import { For, JSX } from "solid-js"; 3 + import { setOpenManager } from "../auth/state"; 4 + import { Button } from "../components/button"; 5 + import { SearchButton } from "../components/search"; 6 + 7 + type ProfileData = { 8 + did: string; 9 + handle: string; 10 + }; 12 11 12 + export const Home = () => { 13 + const FooterLink = (props: { 14 + href: string; 15 + color: string; 16 + darkColor?: string; 17 + children: JSX.Element; 18 + }) => ( 19 + <a 20 + href={props.href} 21 + class={`relative flex items-center gap-1.5 after:absolute after:bottom-0 after:left-0 after:h-px after:w-0 after:bg-current ${props.color} after:transition-[width] after:duration-300 after:ease-out hover:after:w-full ${props.darkColor ?? ""}`} 22 + target="_blank" 23 + > 24 + {props.children} 25 + </a> 26 + ); 27 + 28 + const allExampleProfiles: ProfileData[] = [ 29 + { did: "did:plc:7vimlesenouvuaqvle42yhvo", handle: "juli.ee" }, 30 + { did: "did:plc:oisofpd7lj26yvgiivf3lxsi", handle: "hailey.at" }, 31 + { did: "did:plc:vwzwgnygau7ed7b7wt5ux7y2", handle: "retr0.id" }, 32 + { did: "did:plc:vc7f4oafdgxsihk4cry2xpze", handle: "jcsalterego.bsky.social" }, 33 + { did: "did:plc:uu5axsmbm2or2dngy4gwchec", handle: "futur.blue" }, 34 + { did: "did:plc:ia76kvnndjutgedggx2ibrem", handle: "mary.my.id" }, 35 + { did: "did:plc:hdhoaan3xa3jiuq4fg4mefid", handle: "bad-example.com" }, 36 + { did: "did:plc:q6gjnaw2blty4crticxkmujt", handle: "jaz.sh" }, 37 + { did: "did:plc:jrtgsidnmxaen4offglr5lsh", handle: "quilling.dev" }, 38 + { did: "did:plc:3c6vkaq7xf5kz3va3muptjh5", handle: "aylac.top" }, 39 + { did: "did:plc:gwd5r7dbg3zv6dhv75hboa3f", handle: "mofu.run" }, 40 + { did: "did:plc:tzrpqyerzt37pyj54hh52xrz", handle: "rainy.pet" }, 41 + { did: "did:plc:qx7in36j344d7qqpebfiqtew", handle: "futanari.observer" }, 42 + { did: "did:plc:ucaezectmpny7l42baeyooxi", handle: "sapphic.moe" }, 43 + { did: "did:plc:6v6jqsy7swpzuu53rmzaybjy", handle: "computer.fish" }, 44 + { did: "did:plc:w4nvvt6feq2l3qgnwl6a7g7d", handle: "emilia.wtf" }, 45 + { did: "did:plc:xwhsmuozq3mlsp56dyd7copv", handle: "paizuri.moe" }, 46 + { did: "did:plc:aokggmp5jzj4nc5jifhiplqc", handle: "dreary.blacksky.app" }, 47 + { did: "did:plc:k644h4rq5bjfzcetgsa6tuby", handle: "natalie.sh" }, 48 + { did: "did:plc:ttdrpj45ibqunmfhdsb4zdwq", handle: "nekomimi.pet" }, 49 + { did: "did:plc:fz2tul67ziakfukcwa3vdd5d", handle: "nullekko.moe" }, 50 + { did: "did:plc:qxichs7jsycphrsmbujwqbfb", handle: "isabelroses.com" }, 51 + { did: "did:plc:fnvdhaoe7b5abgrtvzf4ttl5", handle: "isuggest.selfce.st" }, 52 + { did: "did:plc:p5yjdr64h7mk5l3kh6oszryk", handle: "blooym.dev" }, 53 + { did: "did:plc:hvakvedv6byxhufjl23mfmsd", handle: "number-one-warned.rat.mom" }, 54 + { did: "did:plc:6if5m2yo6kroprmmency3gt5", handle: "olaren.dev" }, 55 + { did: "did:plc:w7adfxpixpi77e424cjjxnxy", handle: "anyaustin.bsky.social" }, 56 + { did: "did:plc:h6as5sk7tfqvvnqvfrlnnwqn", handle: "cwonus.org" }, 57 + { did: "did:plc:mo7bk6gblylupvhetkqmndrv", handle: "claire.on-her.computer" }, 58 + { did: "did:plc:73gqgbnvpx5syidcponjrics", handle: "coil-habdle.ebil.club" }, 59 + ]; 13 60 61 + const profiles = [...allExampleProfiles].sort(() => Math.random() - 0.5).slice(0, 3); 14 62 15 - . 16 - </span> 63 + return ( 64 + <div class="flex w-full flex-col gap-5 px-2 wrap-break-word"> 65 + {/* Welcome Section */} 66 + <div class="flex flex-col gap-4"> 67 + <div class="flex flex-col gap-1"> 68 + <h1 class="text-lg font-medium">Atmosphere Explorer</h1> 69 + <div class="text-sm text-neutral-600 dark:text-neutral-300"> 70 + <p> 71 + Browse the public data on the{" "} 72 + <a 73 + href="https://atproto.com" 74 + target="_blank" 75 + class="underline decoration-neutral-400 transition-colors hover:text-blue-500 hover:decoration-blue-500 dark:decoration-neutral-500 dark:hover:text-blue-400" 76 + > 77 + AT Protocol 78 + </a> 79 + </p> 80 + </div> 17 81 </div> 18 - <div class="flex items-center gap-1"> 19 - <div class="iconify lucide--user-round" /> 20 - <span>Login to manage records in your repository.</span> 82 + 83 + {/* Example Repos */} 84 + <section class="mb-1 flex flex-col gap-3"> 85 + <div class="flex justify-between"> 86 + <For each={profiles}> 87 + {(profile) => ( 88 + <A 89 + href={`/at://${profile.did}`} 90 + class="group flex min-w-0 basis-1/3 flex-col items-center gap-1.5 transition-transform hover:scale-105 active:scale-105" 91 + > 92 + <img 93 + src={`/avatar/${profile.handle}.jpg`} 94 + alt={`Bluesky profile picture of ${profile.handle}`} 95 + class="size-16 rounded-full ring-2 ring-transparent transition-all group-hover:ring-blue-500 active:ring-blue-500 dark:group-hover:ring-blue-400 dark:active:ring-blue-400" 96 + classList={{ 97 + "animate-[spin_5s_linear_infinite] [animation-play-state:paused] group-hover:[animation-play-state:running]": 98 + profile.handle === "coil-habdle.ebil.club", 99 + }} 100 + /> 101 + <span class="w-full truncate text-center text-xs text-neutral-600 dark:text-neutral-300"> 102 + @{profile.handle} 103 + </span> 104 + </A> 105 + )} 106 + </For> 107 + </div> 108 + </section> 109 + <div class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400"> 110 + <SearchButton /> 111 + <span>to find any account</span> 21 112 </div> 22 - <div class="flex items-center gap-1"> 23 - <div class="iconify lucide--radio-tower" /> 24 - <span>Jetstream and firehose streaming.</span> 113 + <div class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400"> 114 + <Button onClick={() => setOpenManager(true)}> 115 + <span class="iconify lucide--user-round"></span> 116 + Sign in 117 + </Button> 118 + <span>to manage records</span> 25 119 </div> 26 - <div class="flex items-center gap-1"> 27 - <div class="iconify lucide--link" /> 28 - <span> 29 - 30 - 31 - 32 - 33 - 34 - 120 + </div> 35 121 122 + <div class="flex flex-col gap-4 text-sm"> 123 + <div class="flex flex-col gap-2"> 124 + <A 125 + href="/jetstream" 126 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 127 + > 128 + <div class="iconify lucide--radio-tower" /> 129 + <span class="underline decoration-transparent group-hover:decoration-current"> 130 + Jetstream 131 + </span> 132 + <div /> 133 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 134 + Event stream with filtering 135 + </span> 136 + </A> 137 + <A 138 + href="/firehose" 139 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 140 + > 141 + <div class="iconify lucide--rss" /> 142 + <span class="underline decoration-transparent group-hover:decoration-current"> 143 + Firehose 144 + </span> 145 + <div /> 146 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 147 + Raw relay event stream 148 + </span> 149 + </A> 150 + <A 151 + href="/spacedust" 152 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 153 + > 154 + <div class="iconify lucide--orbit" /> 155 + <span class="underline decoration-transparent group-hover:decoration-current"> 156 + Spacedust 157 + </span> 158 + <div /> 159 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 160 + Interaction links stream 161 + </span> 162 + </A> 163 + </div> 36 164 37 - . 38 - </span> 165 + <div class="flex flex-col gap-2"> 166 + <A 167 + href="/labels" 168 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 169 + > 170 + <div class="iconify lucide--tag" /> 171 + <span class="underline decoration-transparent group-hover:decoration-current"> 172 + Labels 173 + </span> 174 + <div /> 175 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 176 + Query labeler services 177 + </span> 178 + </A> 179 + <A 180 + href="/car" 181 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 182 + > 183 + <div class="iconify lucide--folder-archive" /> 184 + <span class="underline decoration-transparent group-hover:decoration-current"> 185 + Archive 186 + </span> 187 + <div /> 188 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 189 + Explore and unpack CAR files 190 + </span> 191 + </A> 39 192 </div> 40 - <div class="flex items-center gap-1"> 41 - <div class="iconify lucide--tag" /> 42 - <span>Query labels from moderation services.</span> 193 + </div> 194 + 195 + <div class="flex justify-center gap-1.5 text-sm text-neutral-600 sm:gap-2 dark:text-neutral-300"> 196 + <FooterLink 197 + href="https://juli.ee" 198 + color="after:text-rose-400" 199 + darkColor="dark:after:text-rose-300" 200 + > 201 + <span class="iconify lucide--terminal text-rose-400 dark:text-rose-300"></span> 202 + <span class="font-pecita">juliet</span> 203 + </FooterLink> 204 + {/* โ€ข */} 205 + {/* <FooterLink href="https://raycast.com/" color="after:text-[#FF6363]"> */} 206 + {/* <span class="iconify-color i-raycast-light block dark:hidden"></span> */} 207 + {/* <span class="iconify-color i-raycast-dark hidden dark:block"></span> */} 208 + {/* Raycast */} 209 + {/* </FooterLink> */}โ€ข 210 + <FooterLink 211 + href="https://bsky.app/profile/did:plc:6q5daed5gutiyerimlrnojnz" 212 + color="after:text-[#0085ff]" 213 + > 214 + <span class="simple-icons--bluesky iconify text-[#0085ff]"></span> 215 + Bluesky 216 + </FooterLink> 217 + โ€ข 218 + <FooterLink 219 + href="https://tangled.org/did:plc:6q5daed5gutiyerimlrnojnz/pdsls/" 220 + color="after:text-black" 221 + darkColor="dark:after:text-white" 222 + > 223 + <span class="iconify i-tangled text-black dark:text-white"></span> 224 + Source 225 + </FooterLink> 226 + </div> 227 + </div> 228 + );
+2 -2
src/components/sticky.tsx
··· 29 29 /> 30 30 31 31 <div 32 - class="sticky top-2 z-10 flex w-full flex-col items-center justify-center gap-2 rounded-lg p-3 transition-colors" 32 + class="sticky top-2 z-10 flex w-full flex-col items-center justify-center gap-2 rounded-lg border-[0.5px] p-3 transition-colors" 33 33 classList={{ 34 - "bg-neutral-50 dark:bg-dark-300 border-[0.5px] border-neutral-300 dark:border-neutral-700 shadow-md": 34 + "bg-neutral-50 dark:bg-dark-300 border-neutral-300 dark:border-neutral-700 shadow-md": 35 35 filterStuck(), 36 36 "bg-transparent border-transparent shadow-none": !filterStuck(), 37 37 }}
+105 -51
src/components/navbar.tsx
··· 1 + import * as TID from "@atcute/tid"; 2 + import { A, Params } from "@solidjs/router"; 3 + import { createEffect, createMemo, createSignal, Show } from "solid-js"; 4 + import { isTouchDevice } from "../layout"; 5 + import { didDocCache } from "../utils/api"; 6 + import { addToClipboard } from "../utils/copy"; 7 + import { localDateFromTimestamp } from "../utils/date"; 8 + import Tooltip from "./tooltip"; 1 9 2 - 3 - 4 - 5 - 6 - 7 - 8 - 10 + export const [pds, setPDS] = createSignal<string>(); 9 11 10 12 11 13 ··· 18 20 e.stopPropagation(); 19 21 addToClipboard(props.content); 20 22 }} 21 - class={`-mr-2 hidden items-center rounded px-2 py-1.5 text-neutral-500 transition-all duration-200 group-hover:flex hover:bg-neutral-200/70 hover:text-neutral-600 active:bg-neutral-300/70 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-300 dark:active:bg-neutral-600/70`} 23 + class={`-mr-2 hidden items-center rounded px-2 py-1 text-neutral-500 transition-all duration-200 group-hover:flex 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`} 22 24 aria-label="Copy to clipboard" 23 25 > 24 - <span class="iconify lucide--link"></span> 25 - 26 - 27 - 26 + <span class="iconify lucide--copy"></span> 27 + </button> 28 + </Tooltip> 29 + </Show> 28 30 29 31 30 32 31 33 export const NavBar = (props: { params: Params }) => { 32 34 const [handle, setHandle] = createSignal(props.params.repo); 33 - const [showHandle, setShowHandle] = createSignal(localStorage.showHandle === "true"); 34 35 35 36 createEffect(() => { 36 37 if (pds() !== undefined && props.params.repo) { ··· 39 40 40 41 41 42 43 + } 44 + }); 42 45 46 + const rkeyTimestamp = createMemo(() => { 47 + if (!props.params.rkey || !TID.validate(props.params.rkey)) return undefined; 48 + const timestamp = TID.parse(props.params.rkey).timestamp / 1000; 49 + return timestamp <= Date.now() ? timestamp : undefined; 50 + }); 43 51 52 + return ( 53 + <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 54 + {/* PDS Level */} 44 55 45 56 46 57 58 + <span 59 + classList={{ 60 + "iconify shrink-0 transition-colors duration-200": true, 61 + "lucide--alert-triangle text-red-500 dark:text-red-400": 62 + pds() === "Missing PDS" && props.params.repo?.startsWith("did:"), 63 + "lucide--hard-drive text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200": 64 + pds() !== "Missing PDS" || !props.params.repo?.startsWith("did:"), 65 + }} 66 + ></span> 67 + </Tooltip> 68 + <Show when={pds() && (pds() !== "Missing PDS" || props.params.repo?.startsWith("did:"))}> 69 + <Show 70 + when={pds() === "Missing PDS"} 71 + fallback={ 47 72 48 73 49 74 50 75 76 + <A 77 + end 78 + href={pds()!} 79 + inactiveClass="text-blue-500 py-0.5 w-full font-medium hover:text-blue-600 transition-colors duration-150 dark:text-blue-400 dark:hover:text-blue-300" 80 + > 81 + {pds()} 82 + </A> 51 83 52 84 53 85 ··· 61 93 62 94 63 95 96 + <Show when={props.params.repo}> 97 + {/* Repository Level */} 98 + <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"> 99 + <div class="flex min-w-0 basis-full items-center gap-2"> 100 + <Tooltip text="Repository"> 101 + <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"></span> 102 + </Tooltip> 103 + <Show 104 + when={props.params.collection} 105 + fallback={ 106 + <span class="flex min-w-0 gap-1 py-0.5 font-medium"> 107 + <Show 108 + when={handle() !== props.params.repo} 109 + fallback={<span class="truncate">{props.params.repo}</span>} 110 + > 111 + <span class="shrink-0">{handle()}</span> 112 + <span class="truncate text-neutral-500 dark:text-neutral-400"> 113 + ({props.params.repo}) 114 + </span> 115 + </Show> 116 + </span> 117 + } 118 + > 119 + <A 120 + end 121 + href={`/at://${props.params.repo}`} 122 + inactiveClass="flex grow min-w-0 gap-1 py-0.5 font-medium text-blue-500 hover:text-blue-600 transition-colors duration-150 dark:text-blue-400 dark:hover:text-blue-300" 123 + > 124 + <Show 125 + when={handle() !== props.params.repo} 126 + fallback={<span class="truncate">{props.params.repo}</span>} 127 + > 128 + <span class="shrink-0">{handle()}</span> 129 + <span class="truncate">({props.params.repo})</span> 130 + </Show> 131 + </A> 132 + </Show> 133 + </div> 134 + <CopyButton content={props.params.repo!} label="Copy DID" /> 135 + </div> 136 + </Show> 64 137 65 138 66 139 ··· 73 146 74 147 75 148 149 + <A 150 + end 151 + href={`/at://${props.params.repo}/${props.params.collection}`} 152 + inactiveClass="text-blue-500 dark:text-blue-400 grow py-0.5 font-medium hover:text-blue-600 transition-colors duration-150 dark:hover:text-blue-300" 153 + > 154 + {props.params.collection} 155 + </A> 76 156 77 157 78 158 ··· 86 166 87 167 88 168 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - <A 97 - end 98 - href={`/at://${props.params.repo}`} 99 - inactiveClass="text-blue-400 w-full py-0.5 font-medium hover:text-blue-500 transition-colors duration-150 dark:hover:text-blue-300" 100 - > 101 - {showHandle() ? handle() : props.params.repo} 102 - </A> 103 - : <span class="py-0.5 font-medium"> 104 - {showHandle() ? handle() : props.params.repo} 105 - </span> 106 - } 107 - </div> 108 - <div class="flex"> 109 - <Tooltip text={showHandle() ? "Show DID" : "Show handle"}> 110 - <button 111 - type="button" 112 - class={`items-center rounded px-1.25 py-1.25 text-neutral-500 transition-all duration-200 hover:bg-neutral-200/70 hover:text-neutral-700 active:bg-neutral-300/70 sm:px-2 sm:py-1.5 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-200 dark:active:bg-neutral-600/70 ${isTouchDevice ? "flex" : "hidden group-hover:flex"}`} 113 - onclick={() => { 114 - localStorage.showHandle = !showHandle(); 115 - setShowHandle(!showHandle()); 116 - }} 117 - aria-label="Switch DID/Handle" 118 - > 119 - <span 120 - class={`iconify shrink-0 duration-200 ${showHandle() ? "rotate-y-180" : ""} lucide--arrow-left-right`} 121 - ></span> 122 - </button> 169 + <Tooltip text="Record"> 170 + <span class="iconify lucide--file-json text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200"></span> 123 171 </Tooltip> 124 - <CopyButton content={props.params.repo!} label="Copy DID" /> 172 + <div class="flex min-w-0 gap-1 py-0.5 font-medium"> 173 + <span class="shrink-0">{props.params.rkey}</span> 174 + <Show when={rkeyTimestamp()}> 175 + <span class="truncate text-neutral-500 dark:text-neutral-400"> 176 + ({localDateFromTimestamp(rkeyTimestamp()!)}) 177 + </span> 178 + </Show> 179 + </div> 125 180 </div> 126 - </div> 127 - </Show> 128 - 181 + <CopyButton 182 + content={`at://${props.params.repo}/${props.params.collection}/${props.params.rkey}`}
+142 -173
src/components/backlinks.tsx
··· 1 1 import * as TID from "@atcute/tid"; 2 2 import { createResource, createSignal, For, onMount, Show } from "solid-js"; 3 - import { 4 - getAllBacklinks, 5 - getDidBacklinks, 6 - getRecordBacklinks, 7 - LinksWithDids, 8 - LinksWithRecords, 9 - } from "../utils/api.js"; 3 + import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../utils/api.js"; 10 4 import { localDateFromTimestamp } from "../utils/date.js"; 11 5 import { Button } from "./button.jsx"; 6 + import { Favicon } from "./favicon.jsx"; 7 + import DidHoverCard from "./hover-card/did.jsx"; 8 + import RecordHoverCard from "./hover-card/record.jsx"; 12 9 13 - type Backlink = { 10 + type BacklinksProps = { 11 + target: string; 12 + collection: string; 13 + path: string; 14 + }; 15 + 16 + type BacklinkEntry = { 17 + collection: string; 14 18 path: string; 15 19 counts: { distinct_dids: number; records: number }; 16 20 }; 17 21 18 - const linksBySource = (links: Record<string, any>) => { 19 - let out: Record<string, Backlink[]> = {}; 22 + const flattenLinks = (links: Record<string, any>): BacklinkEntry[] => { 23 + const entries: BacklinkEntry[] = []; 20 24 Object.keys(links) 21 25 .toSorted() 22 26 .forEach((collection) => { ··· 24 28 Object.keys(paths) 25 29 .toSorted() 26 30 .forEach((path) => { 27 - if (paths[path].records === 0) return; 28 - if (out[collection]) out[collection].push({ path, counts: paths[path] }); 29 - else out[collection] = [{ path, counts: paths[path] }]; 31 + if (paths[path].records > 0) { 32 + entries.push({ collection, path, counts: paths[path] }); 33 + } 30 34 }); 31 35 }); 32 - return out; 36 + return entries; 33 37 }; 34 38 35 - const Backlinks = (props: { target: string }) => { 36 - const fetchBacklinks = async () => { 37 - const res = await getAllBacklinks(props.target); 38 - return linksBySource(res.links); 39 - }; 40 - 41 - const [response] = createResource(fetchBacklinks); 39 + const BacklinkRecords = (props: BacklinksProps & { cursor?: string }) => { 40 + const [links, setLinks] = createSignal<LinksWithRecords>(); 41 + const [more, setMore] = createSignal(false); 42 42 43 - const [show, setShow] = createSignal<{ 44 - collection: string; 45 - path: string; 46 - showDids: boolean; 47 - } | null>(); 43 + onMount(async () => { 44 + const res = await getRecordBacklinks(props.target, props.collection, props.path, props.cursor); 45 + setLinks(res); 46 + }); 48 47 49 48 return ( 50 - <div class="flex w-full flex-col gap-1 text-sm wrap-anywhere"> 51 - <Show 52 - when={response() && Object.keys(response()!).length} 53 - fallback={<p>No backlinks found.</p>} 54 - > 55 - <For each={Object.keys(response()!)}> 56 - {(collection) => ( 57 - <div> 58 - <div class="flex items-center gap-1"> 59 - <span class="iconify lucide--book-text shrink-0"></span> 60 - {collection} 61 - </div> 62 - <For each={response()![collection]}> 63 - {({ path, counts }) => ( 64 - <div class="ml-4.5"> 65 - <div class="flex items-center gap-1"> 66 - <span class="iconify lucide--route shrink-0"></span> 67 - {path.slice(1)} 68 - </div> 69 - <div class="ml-4.5"> 70 - <p> 71 - <button 72 - class="text-blue-400 hover:underline active:underline" 73 - onclick={() => 74 - ( 75 - show()?.collection === collection && 76 - show()?.path === path && 77 - !show()?.showDids 78 - ) ? 79 - setShow(null) 80 - : setShow({ collection, path, showDids: false }) 81 - } 82 - > 83 - {counts.records} record{counts.records < 2 ? "" : "s"} 84 - </button> 85 - {" from "} 86 - <button 87 - class="text-blue-400 hover:underline active:underline" 88 - onclick={() => 89 - ( 90 - show()?.collection === collection && 91 - show()?.path === path && 92 - show()?.showDids 93 - ) ? 94 - setShow(null) 95 - : setShow({ collection, path, showDids: true }) 96 - } 97 - > 98 - {counts.distinct_dids} DID 99 - {counts.distinct_dids < 2 ? "" : "s"} 100 - </button> 101 - </p> 102 - <Show when={show()?.collection === collection && show()?.path === path}> 103 - <Show when={show()?.showDids}> 104 - <p class="w-full font-semibold">Distinct identities</p> 105 - <BacklinkItems 106 - target={props.target} 107 - collection={collection} 108 - path={path} 109 - dids={true} 110 - /> 111 - </Show> 112 - <Show when={!show()?.showDids}> 113 - <p class="w-full font-semibold">Records</p> 114 - <BacklinkItems 115 - target={props.target} 116 - collection={collection} 117 - path={path} 118 - dids={false} 119 - /> 120 - </Show> 121 - </Show> 122 - </div> 123 - </div> 124 - )} 125 - </For> 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> 126 98 </div> 127 - )} 128 - </For> 99 + } 100 + > 101 + <BacklinkRecords 102 + target={props.target} 103 + collection={props.collection} 104 + path={props.path} 105 + cursor={links()!.cursor} 106 + /> 107 + </Show> 129 108 </Show> 130 - </div> 109 + </Show> 131 110 ); 132 111 }; 133 112 134 - // switching on !!did everywhere is pretty annoying, this could probably be two components 135 - // but i don't want to duplicate or think about how to extract the paging logic 136 - const BacklinkItems = ({ 137 - target, 138 - collection, 139 - path, 140 - dids, 141 - cursor, 142 - }: { 143 - target: string; 144 - collection: string; 145 - path: string; 146 - dids: boolean; 147 - cursor?: string; 148 - }) => { 149 - const [links, setLinks] = createSignal<LinksWithDids | LinksWithRecords>(); 150 - const [more, setMore] = createSignal<boolean>(false); 151 - 152 - onMount(async () => { 153 - const links = await (dids ? getDidBacklinks : getRecordBacklinks)( 154 - target, 155 - collection, 156 - path, 157 - cursor, 158 - ); 159 - setLinks(links); 113 + const Backlinks = (props: { target: string }) => { 114 + const [response] = createResource(async () => { 115 + const res = await getAllBacklinks(props.target); 116 + return flattenLinks(res.links); 160 117 }); 161 118 162 - // TODO: could pass the `total` into this component, which can be checked against each call to this endpoint to find if it's stale. 163 - // also hmm 'total' is misleading/wrong on that api 164 - 165 119 return ( 166 - <Show when={links()} fallback={<p>Loading&hellip;</p>}> 167 - <Show when={dids}> 168 - <For each={(links() as LinksWithDids).linking_dids}> 169 - {(did) => ( 170 - <a 171 - href={`/at://${did}`} 172 - class="relative flex w-full font-mono text-blue-400 hover:underline active:underline" 173 - > 174 - {did} 175 - </a> 176 - )} 177 - </For> 178 - </Show> 179 - <Show when={!dids}> 180 - <For each={(links() as LinksWithRecords).linking_records}> 181 - {({ did, collection, rkey }) => ( 182 - <p class="relative flex w-full items-center gap-1 font-mono"> 183 - <a 184 - href={`/at://${did}/${collection}/${rkey}`} 185 - class="text-blue-400 hover:underline active:underline" 186 - > 187 - {rkey} 188 - </a> 189 - <span class="text-xs text-neutral-500 dark:text-neutral-400"> 190 - {TID.validate(rkey) ? 191 - localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) 192 - : undefined} 193 - </span> 194 - </p> 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 + /> 195 133 )} 196 134 </For> 197 135 </Show> 198 - <Show when={links()?.cursor}> 199 - <Show when={more()} fallback={<Button onClick={() => setMore(true)}>Load More</Button>}> 200 - <BacklinkItems 201 - target={target} 202 - collection={collection} 203 - path={path} 204 - dids={dids} 205 - cursor={links()!.cursor} 136 + </div> 137 + ); 138 + }; 139 + 140 + const 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() }} 206 170 /> 207 - </Show> 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> 208 177 </Show> 209 - </Show> 178 + </div> 210 179 ); 211 180 }; 212 181
+27 -7
src/auth/login.tsx
··· 29 29 30 30 31 31 32 + }; 32 33 34 + return ( 35 + <div class="flex flex-col gap-y-3 px-1"> 36 + <Show when={!scopeFlow.showScopeSelector()}> 37 + <Show when={props.onCancel}> 38 + <div class="flex items-center gap-2"> 39 + <button 40 + onclick={handleCancel} 41 + class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 33 42 34 43 35 44 45 + <div class="font-semibold">Add account</div> 46 + </div> 47 + </Show> 48 + <form class="flex flex-col gap-3" onsubmit={(e) => e.preventDefault()}> 49 + <label for="username" class="hidden"> 50 + Add account 51 + </label> 52 + <div class="dark:bg-dark-100 flex grow items-center gap-2 rounded-lg bg-white px-2 outline-1 outline-neutral-200 focus-within:outline-[1.5px] focus-within:outline-neutral-600 dark:outline-neutral-600 dark:focus-within:outline-neutral-400"> 53 + <label 54 + for="username" 55 + class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400" 36 56 37 57 38 58 ··· 46 66 47 67 48 68 49 - <label for="username" class="hidden"> 50 - Add account 51 - </label> 52 - <div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex grow items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400"> 53 - <label 54 - for="username" 55 - class="iconify lucide--user-round-plus shrink-0 text-neutral-500 dark:text-neutral-400" 69 + </div> 70 + <button 71 + onclick={() => initiateLogin(loginInput())} 72 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 73 + > 74 + Continue 75 + </button>
+1 -1
src/components/text-input.tsx
··· 25 25 disabled={props.disabled} 26 26 required={props.required} 27 27 class={ 28 - "dark:bg-dark-100 dark:inset-shadow-dark-200 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 inset-shadow-xs select-none placeholder:text-sm focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400 " + 28 + "dark:bg-dark-100 rounded-md bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 29 29 props.class 30 30 } 31 31 onInput={props.onInput}
+8 -7
src/components/dropdown.tsx
··· 69 69 70 70 71 71 72 + ); 73 + }; 72 74 75 + export const ActionMenu = (props: { label: string; icon: string; onClick: () => void }) => { 76 + const ctx = useContext(MenuContext); 73 77 74 - 75 - export const ActionMenu = (props: { 76 - label: string; 77 - icon: string; 78 - onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 79 - }) => { 80 78 return ( 81 79 <button 82 - onClick={props.onClick} 80 + onClick={() => { 81 + props.onClick(); 82 + ctx?.setShowMenu(false); 83 + }} 83 84 class="flex items-center gap-2 rounded-md p-1.5 whitespace-nowrap hover:bg-neutral-200/50 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 84 85 > 85 86 <Show when={props.icon}>
+25
src/utils/route-cache.ts
··· 1 + import { createStore } from "solid-js/store"; 2 + 3 + export interface CollectionCacheEntry { 4 + records: unknown[]; 5 + cursor: string | undefined; 6 + scrollY: number; 7 + reverse: boolean; 8 + limit: number; 9 + } 10 + 11 + type RouteCache = Record<string, CollectionCacheEntry>; 12 + 13 + const [routeCache, setRouteCache] = createStore<RouteCache>({}); 14 + 15 + export const getCollectionCache = (key: string): CollectionCacheEntry | undefined => { 16 + return routeCache[key]; 17 + }; 18 + 19 + export const setCollectionCache = (key: string, entry: CollectionCacheEntry): void => { 20 + setRouteCache(key, entry); 21 + }; 22 + 23 + export const clearCollectionCache = (key: string): void => { 24 + setRouteCache(key, undefined!); 25 + };
+11 -12
src/components/theme.tsx
··· 8 8 9 9 export const ThemeSelection = () => { 10 10 const [theme, setTheme] = createSignal( 11 - localStorage.getItem("theme") === null ? "system" 11 + localStorage.getItem("theme") === null ? "auto" 12 12 : localStorage.theme === "dark" ? "dark" 13 13 : "light", 14 14 ); ··· 18 18 document.documentElement.classList.toggle( 19 19 "dark", 20 20 newTheme === "dark" || 21 - (newTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches), 21 + (newTheme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches), 22 22 ); 23 - if (newTheme === "system") localStorage.removeItem("theme"); 23 + if (newTheme === "auto") localStorage.removeItem("theme"); 24 24 else localStorage.theme = newTheme; 25 25 }; 26 26 27 - const ThemeOption = (props: { theme: string; icon: string; label: string }) => { 27 + const ThemeOption = (props: { theme: string; label: string }) => { 28 28 return ( 29 29 <button 30 30 classList={{ 31 - "flex items-center gap-2 rounded-xl border px-3 py-2": true, 31 + "flex items-center min-w-21 justify-center rounded-xl border px-3 py-2": true, 32 32 "bg-neutral-200/60 border-neutral-300 dark:border-neutral-500 dark:bg-neutral-700": 33 33 theme() === props.theme, 34 34 "border-neutral-200 dark:border-neutral-600 hover:bg-neutral-200/30 dark:hover:bg-neutral-800": ··· 36 36 }} 37 37 onclick={() => updateTheme(props.theme)} 38 38 > 39 - <span class={"iconify " + props.icon}></span> 40 - <span>{props.label}</span> 39 + {props.label} 41 40 </button> 42 41 ); 43 42 }; 44 43 45 44 return ( 46 - <div class="flex flex-col gap-0.5"> 47 - <label class="select-none">Theme</label> 45 + <div class="flex flex-col gap-1"> 46 + <label class="font-medium select-none">Theme</label> 48 47 <div class="flex gap-2"> 49 - <ThemeOption theme="system" icon="lucide--monitor" label="System" /> 50 - <ThemeOption theme="light" icon="lucide--sun" label="Light" /> 51 - <ThemeOption theme="dark" icon="lucide--moon" label="Dark" /> 48 + <ThemeOption theme="auto" label="Auto" /> 49 + <ThemeOption theme="light" label="Light" /> 50 + <ThemeOption theme="dark" label="Dark" /> 52 51 </div> 53 52 </div> 54 53 );
public/ribbon.webp

This is a binary file and will not be displayed.

+123 -119
src/views/stream.tsx
··· 1 1 import { Firehose } from "@skyware/firehose"; 2 + import { Title } from "@solidjs/meta"; 2 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 3 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 4 5 import { Button } from "../components/button"; ··· 169 170 }); 170 171 171 172 return ( 172 - <div class="flex w-full flex-col items-center"> 173 - <div class="mb-1 flex gap-4 font-medium"> 174 - <A 175 - class="flex items-center gap-1 border-b-2" 176 - inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 177 - href="/jetstream" 178 - > 179 - Jetstream 180 - </A> 181 - <A 182 - class="flex items-center gap-1 border-b-2" 183 - inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 184 - href="/firehose" 185 - > 186 - Firehose 187 - </A> 188 - </div> 189 - <StickyOverlay> 190 - <form ref={formRef} class="flex w-full flex-col gap-1.5 text-sm"> 191 - <Show when={!connected()}> 192 - <label class="flex items-center justify-end gap-x-1"> 193 - <span class="min-w-20">Instance</span> 194 - <TextInput 195 - name="instance" 196 - value={ 197 - searchParams.instance ?? 198 - (streamType === "jetstream" ? 199 - "wss://jetstream1.us-east.bsky.network/subscribe" 200 - : "wss://bsky.network") 201 - } 202 - class="grow" 203 - /> 204 - </label> 205 - <Show when={streamType === "jetstream"}> 173 + <> 174 + <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 175 + <div class="flex w-full flex-col items-center"> 176 + <div class="mb-1 flex gap-4 font-medium"> 177 + <A 178 + class="flex items-center gap-1 border-b-2" 179 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 180 + href="/jetstream" 181 + > 182 + Jetstream 183 + </A> 184 + <A 185 + class="flex items-center gap-1 border-b-2" 186 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 187 + href="/firehose" 188 + > 189 + Firehose 190 + </A> 191 + </div> 192 + <StickyOverlay> 193 + <form ref={formRef} class="flex w-full flex-col gap-1.5 text-sm"> 194 + <Show when={!connected()}> 206 195 <label class="flex items-center justify-end gap-x-1"> 207 - <span class="min-w-20">Collections</span> 208 - <textarea 209 - name="collections" 210 - spellcheck={false} 211 - placeholder="Comma-separated list of collections" 212 - value={searchParams.collections ?? ""} 213 - class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 196 + <span class="min-w-20">Instance</span> 197 + <TextInput 198 + name="instance" 199 + value={ 200 + searchParams.instance ?? 201 + (streamType === "jetstream" ? 202 + "wss://jetstream1.us-east.bsky.network/subscribe" 203 + : "wss://bsky.network") 204 + } 205 + class="grow" 214 206 /> 215 207 </label> 216 - </Show> 217 - <Show when={streamType === "jetstream"}> 208 + <Show when={streamType === "jetstream"}> 209 + <label class="flex items-center justify-end gap-x-1"> 210 + <span class="min-w-20">Collections</span> 211 + <textarea 212 + name="collections" 213 + spellcheck={false} 214 + placeholder="Comma-separated list of collections" 215 + value={searchParams.collections ?? ""} 216 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 217 + /> 218 + </label> 219 + </Show> 220 + <Show when={streamType === "jetstream"}> 221 + <label class="flex items-center justify-end gap-x-1"> 222 + <span class="min-w-20">DIDs</span> 223 + <textarea 224 + name="dids" 225 + spellcheck={false} 226 + placeholder="Comma-separated list of DIDs" 227 + value={searchParams.dids ?? ""} 228 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 229 + /> 230 + </label> 231 + </Show> 218 232 <label class="flex items-center justify-end gap-x-1"> 219 - <span class="min-w-20">DIDs</span> 220 - <textarea 221 - name="dids" 222 - spellcheck={false} 223 - placeholder="Comma-separated list of DIDs" 224 - value={searchParams.dids ?? ""} 225 - class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 233 + <span class="min-w-20">Cursor</span> 234 + <TextInput 235 + name="cursor" 236 + placeholder="Leave empty for live-tail" 237 + value={searchParams.cursor ?? ""} 238 + class="grow" 226 239 /> 227 240 </label> 241 + <Show when={streamType === "jetstream"}> 242 + <div class="flex items-center justify-end gap-x-1"> 243 + <input 244 + type="checkbox" 245 + name="allEvents" 246 + id="allEvents" 247 + checked={searchParams.allEvents === "on" ? true : false} 248 + /> 249 + <label for="allEvents" class="select-none"> 250 + Show account and identity events 251 + </label> 252 + </div> 253 + </Show> 228 254 </Show> 229 - <label class="flex items-center justify-end gap-x-1"> 230 - <span class="min-w-20">Cursor</span> 231 - <TextInput 232 - name="cursor" 233 - placeholder="Leave empty for live-tail" 234 - value={searchParams.cursor ?? ""} 235 - class="grow" 236 - /> 237 - </label> 238 - <Show when={streamType === "jetstream"}> 239 - <div class="flex items-center justify-end gap-x-1"> 240 - <input 241 - type="checkbox" 242 - name="allEvents" 243 - id="allEvents" 244 - checked={searchParams.allEvents === "on" ? true : false} 245 - /> 246 - <label for="allEvents" class="select-none"> 247 - Show account and identity events 248 - </label> 249 - </div> 250 - </Show> 251 - </Show> 252 - <Show when={connected()}> 253 - <div class="flex flex-col gap-1 wrap-anywhere"> 254 - <For each={parameters()}> 255 - {(param) => ( 256 - <Show when={param.param}> 257 - <div class="flex"> 258 - <div class="min-w-24 font-semibold">{param.name}</div> 259 - {param.param} 260 - </div> 261 - </Show> 262 - )} 263 - </For> 264 - </div> 265 - </Show> 266 - <div class="flex justify-end"> 267 255 <Show when={connected()}> 268 - <button 269 - type="button" 270 - onmousedown={(e) => { 271 - e.preventDefault(); 272 - disconnect(); 273 - }} 274 - ontouchstart={(e) => { 275 - e.preventDefault(); 276 - disconnect(); 277 - }} 278 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 279 - > 280 - Disconnect 281 - </button> 282 - </Show> 283 - <Show when={!connected()}> 284 - <Button onClick={() => connectSocket(new FormData(formRef))}>Connect</Button> 256 + <div class="flex flex-col gap-1 wrap-anywhere"> 257 + <For each={parameters()}> 258 + {(param) => ( 259 + <Show when={param.param}> 260 + <div class="flex"> 261 + <div class="min-w-24 font-semibold">{param.name}</div> 262 + {param.param} 263 + </div> 264 + </Show> 265 + )} 266 + </For> 267 + </div> 285 268 </Show> 286 - </div> 287 - </form> 288 - </StickyOverlay> 289 - <Show when={notice().length}> 290 - <div class="text-red-500 dark:text-red-400">{notice()}</div> 291 - </Show> 292 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-3xl"> 293 - <For each={records().toReversed()}> 294 - {(rec) => ( 295 - <div class="pb-2"> 296 - <JSONValue data={rec} repo={rec.did ?? rec.repo} /> 269 + <div class="flex justify-end"> 270 + <Show when={connected()}> 271 + <button 272 + type="button" 273 + onmousedown={(e) => { 274 + e.preventDefault(); 275 + disconnect(); 276 + }} 277 + ontouchstart={(e) => { 278 + e.preventDefault(); 279 + disconnect(); 280 + }} 281 + class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 282 + > 283 + Disconnect 284 + </button> 285 + </Show> 286 + <Show when={!connected()}> 287 + <Button onClick={() => connectSocket(new FormData(formRef))}>Connect</Button> 288 + </Show> 297 289 </div> 298 - )} 299 - </For> 290 + </form> 291 + </StickyOverlay> 292 + <Show when={notice().length}> 293 + <div class="text-red-500 dark:text-red-400">{notice()}</div> 294 + </Show> 295 + <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-3xl"> 296 + <For each={records().toReversed()}> 297 + {(rec) => ( 298 + <div class="pb-2"> 299 + <JSONValue data={rec} repo={rec.did ?? rec.repo} /> 300 + </div> 301 + )} 302 + </For> 303 + </div> 300 304 </div> 301 - </div> 305 + </> 302 306 ); 303 307 }; 304 308
+10 -5
src/components/tooltip.tsx
··· 1 1 2 + import { isTouchDevice } from "../layout"; 2 3 3 - 4 - 5 - 6 - 4 + const Tooltip = (props: { text: string; children: JSX.Element }) => ( 5 + <span class="group/tooltip relative inline-flex items-center"> 6 + {props.children} 7 7 <Show when={!isTouchDevice}> 8 8 <span 9 9 style={`transform: translate(-50%, 28px)`} 10 - class={`dark:shadow-dark-700 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-neutral-50 p-1 text-center font-sans text-xs whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200`} 10 + class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs font-normal whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`} 11 11 > 12 12 {props.text} 13 13 </span> 14 + </Show> 15 + </span> 16 + ); 17 + 18 + export default Tooltip;
+649
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 + const isIOS = 15 + /iPad|iPhone|iPod/.test(navigator.userAgent) || 16 + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 17 + 18 + // Convert CBOR-decoded objects to JSON-friendly format 19 + const toJsonValue = (obj: unknown): JSONType => { 20 + if (obj === null || obj === undefined) return null; 21 + 22 + if (CID.isCidLink(obj)) { 23 + return { $link: obj.$link }; 24 + } 25 + 26 + if ( 27 + obj && 28 + typeof obj === "object" && 29 + "version" in obj && 30 + "codec" in obj && 31 + "digest" in obj && 32 + "bytes" in obj 33 + ) { 34 + try { 35 + return { $link: CID.toString(obj as CID.Cid) }; 36 + } catch {} 37 + } 38 + 39 + if (CBOR.isBytes(obj)) { 40 + return { $bytes: obj.$bytes }; 41 + } 42 + 43 + if (Array.isArray(obj)) { 44 + return obj.map(toJsonValue); 45 + } 46 + 47 + if (typeof obj === "object") { 48 + const result: Record<string, JSONType> = {}; 49 + for (const [key, value] of Object.entries(obj)) { 50 + result[key] = toJsonValue(value); 51 + } 52 + return result; 53 + } 54 + 55 + return obj as JSONType; 56 + }; 57 + 58 + interface Archive { 59 + file: File; 60 + did: string; 61 + entries: CollectionEntry[]; 62 + } 63 + 64 + interface CollectionEntry { 65 + name: string; 66 + entries: RecordEntry[]; 67 + } 68 + 69 + interface RecordEntry { 70 + key: string; 71 + cid: string; 72 + record: JSONType; 73 + } 74 + 75 + type View = 76 + | { type: "repo" } 77 + | { type: "collection"; collection: CollectionEntry } 78 + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 79 + 80 + export const CarView = () => { 81 + const [archive, setArchive] = createSignal<Archive | null>(null); 82 + const [loading, setLoading] = createSignal(false); 83 + const [error, setError] = createSignal<string>(); 84 + const [view, setView] = createSignal<View>({ type: "repo" }); 85 + 86 + const parseCarFile = async (file: File) => { 87 + setLoading(true); 88 + setError(undefined); 89 + 90 + try { 91 + // Read file as ArrayBuffer to extract DID from commit block 92 + const buffer = new Uint8Array(await file.arrayBuffer()); 93 + const car = CAR.fromUint8Array(buffer); 94 + 95 + // Get DID from commit block 96 + let did = ""; 97 + const rootCid = car.roots[0]?.$link; 98 + if (rootCid) { 99 + for (const entry of car) { 100 + if (CID.toString(entry.cid) === rootCid) { 101 + const commit = CBOR.decode(entry.bytes); 102 + if (isCommit(commit)) { 103 + did = commit.did; 104 + } 105 + break; 106 + } 107 + } 108 + } 109 + 110 + const collections = new Map<string, RecordEntry[]>(); 111 + const result: Archive = { 112 + file, 113 + did, 114 + entries: [], 115 + }; 116 + 117 + const stream = file.stream(); 118 + const repo = fromStream(stream); 119 + try { 120 + for await (const entry of repo) { 121 + let list = collections.get(entry.collection); 122 + if (list === undefined) { 123 + collections.set(entry.collection, (list = [])); 124 + result.entries.push({ 125 + name: entry.collection, 126 + entries: list, 127 + }); 128 + } 129 + 130 + const record = toJsonValue(entry.record); 131 + list.push({ 132 + key: entry.rkey, 133 + cid: entry.cid.$link, 134 + record, 135 + }); 136 + } 137 + } finally { 138 + await repo.dispose(); 139 + } 140 + 141 + setArchive(result); 142 + setView({ type: "repo" }); 143 + } catch (err) { 144 + console.error("Failed to parse CAR file:", err); 145 + setError(err instanceof Error ? err.message : "Failed to parse CAR file"); 146 + } finally { 147 + setLoading(false); 148 + } 149 + }; 150 + 151 + const handleFileChange = (e: Event) => { 152 + const input = e.target as HTMLInputElement; 153 + const file = input.files?.[0]; 154 + if (file) { 155 + parseCarFile(file); 156 + } 157 + }; 158 + 159 + const handleDrop = (e: DragEvent) => { 160 + e.preventDefault(); 161 + const file = e.dataTransfer?.files?.[0]; 162 + if (file && (file.name.endsWith(".car") || file.type === "application/vnd.ipld.car")) { 163 + parseCarFile(file); 164 + } 165 + }; 166 + 167 + const handleDragOver = (e: DragEvent) => { 168 + e.preventDefault(); 169 + }; 170 + 171 + const reset = () => { 172 + setArchive(null); 173 + setView({ type: "repo" }); 174 + setError(undefined); 175 + }; 176 + 177 + return ( 178 + <> 179 + <Title>CAR explorer - PDSls</Title> 180 + <div class="flex w-full flex-col items-center"> 181 + <Show 182 + when={archive()} 183 + fallback={ 184 + <WelcomeView 185 + loading={loading()} 186 + error={error()} 187 + onFileChange={handleFileChange} 188 + onDrop={handleDrop} 189 + onDragOver={handleDragOver} 190 + /> 191 + } 192 + > 193 + {(arch) => <ExploreView archive={arch()} view={view} setView={setView} onClose={reset} />} 194 + </Show> 195 + </div> 196 + </> 197 + ); 198 + }; 199 + 200 + const WelcomeView = (props: { 201 + loading: boolean; 202 + error?: string; 203 + onFileChange: (e: Event) => void; 204 + onDrop: (e: DragEvent) => void; 205 + onDragOver: (e: DragEvent) => void; 206 + }) => { 207 + return ( 208 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 209 + <div class="flex flex-col gap-y-1"> 210 + <h1 class="text-lg font-semibold">CAR explorer</h1> 211 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 212 + Upload a CAR (Content Addressable aRchive) file to explore its contents. 213 + </p> 214 + </div> 215 + 216 + <div 217 + 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" 218 + onDrop={props.onDrop} 219 + onDragOver={props.onDragOver} 220 + > 221 + <Show 222 + when={!props.loading} 223 + fallback={ 224 + <div class="flex flex-col items-center gap-2"> 225 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 226 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 227 + Reading CAR file... 228 + </span> 229 + </div> 230 + } 231 + > 232 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 233 + <div class="text-center"> 234 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 235 + Drag and drop a CAR file here 236 + </p> 237 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 238 + </div> 239 + <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"> 240 + <input 241 + type="file" 242 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 243 + onChange={props.onFileChange} 244 + class="hidden" 245 + /> 246 + <span class="iconify lucide--upload text-sm" /> 247 + Choose file 248 + </label> 249 + </Show> 250 + </div> 251 + 252 + <Show when={props.error}> 253 + <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"> 254 + {props.error} 255 + </div> 256 + </Show> 257 + </div> 258 + ); 259 + }; 260 + 261 + const ExploreView = (props: { 262 + archive: Archive; 263 + view: () => View; 264 + setView: (view: View) => void; 265 + onClose: () => void; 266 + }) => { 267 + return ( 268 + <div class="flex w-full flex-col"> 269 + <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 270 + {/* DID / Repository Level */} 271 + <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"> 272 + <Show 273 + when={props.view().type !== "repo"} 274 + fallback={ 275 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7"> 276 + <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" /> 277 + <span class="truncate py-0.5 font-medium">{props.archive.did || "Repository"}</span> 278 + </div> 279 + } 280 + > 281 + <button 282 + type="button" 283 + onClick={() => props.setView({ type: "repo" })} 284 + class="flex min-h-6 min-w-0 basis-full items-center gap-2 px-2 sm:min-h-7" 285 + > 286 + <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" /> 287 + <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"> 288 + {props.archive.did || "Repository"} 289 + </span> 290 + </button> 291 + </Show> 292 + <button 293 + type="button" 294 + onClick={props.onClose} 295 + title="Close and upload a different file" 296 + 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" 297 + > 298 + <span class="iconify lucide--x" /> 299 + </button> 300 + </div> 301 + 302 + {/* Collection Level */} 303 + <Show 304 + when={(() => { 305 + const v = props.view(); 306 + return v.type === "collection" || v.type === "record" ? v.collection : null; 307 + })()} 308 + > 309 + {(collection) => ( 310 + <Show 311 + when={props.view().type === "record"} 312 + fallback={ 313 + <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"> 314 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 315 + <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" /> 316 + <span class="truncate py-0.5 font-medium">{collection().name}</span> 317 + </div> 318 + </div> 319 + } 320 + > 321 + <button 322 + type="button" 323 + onClick={() => props.setView({ type: "collection", collection: collection() })} 324 + 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" 325 + > 326 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 327 + <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" /> 328 + <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"> 329 + {collection().name} 330 + </span> 331 + </div> 332 + </button> 333 + </Show> 334 + )} 335 + </Show> 336 + 337 + {/* Record Level */} 338 + <Show 339 + when={(() => { 340 + const v = props.view(); 341 + return v.type === "record" ? v.record : null; 342 + })()} 343 + > 344 + {(record) => ( 345 + <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"> 346 + <div class="flex min-h-6 min-w-0 basis-full items-center gap-2 sm:min-h-7"> 347 + <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" /> 348 + <span class="truncate py-0.5 font-medium">{record().key}</span> 349 + </div> 350 + </div> 351 + )} 352 + </Show> 353 + </nav> 354 + 355 + <div class="px-2 py-2"> 356 + <Switch> 357 + <Match when={props.view().type === "repo"}> 358 + <RepoSubview archive={props.archive} onRoute={props.setView} /> 359 + </Match> 360 + 361 + <Match 362 + when={(() => { 363 + const v = props.view(); 364 + return v.type === "collection" ? v : null; 365 + })()} 366 + keyed 367 + > 368 + {({ collection }) => ( 369 + <CollectionSubview 370 + archive={props.archive} 371 + collection={collection} 372 + onRoute={props.setView} 373 + /> 374 + )} 375 + </Match> 376 + 377 + <Match 378 + when={(() => { 379 + const v = props.view(); 380 + return v.type === "record" ? v : null; 381 + })()} 382 + keyed 383 + > 384 + {({ collection, record }) => ( 385 + <RecordSubview archive={props.archive} collection={collection} record={record} /> 386 + )} 387 + </Match> 388 + </Switch> 389 + </div> 390 + </div> 391 + ); 392 + }; 393 + 394 + const RepoSubview = (props: { archive: Archive; onRoute: (view: View) => void }) => { 395 + const [filter, setFilter] = createSignal(""); 396 + 397 + const sortedEntries = createMemo(() => { 398 + return [...props.archive.entries].sort((a, b) => a.name.localeCompare(b.name)); 399 + }); 400 + 401 + const filteredEntries = createMemo(() => { 402 + const f = filter().toLowerCase().trim(); 403 + if (!f) return sortedEntries(); 404 + return sortedEntries().filter((entry) => entry.name.toLowerCase().includes(f)); 405 + }); 406 + 407 + const totalRecords = createMemo(() => 408 + props.archive.entries.reduce((sum, entry) => sum + entry.entries.length, 0), 409 + ); 410 + 411 + return ( 412 + <div class="flex flex-col gap-3"> 413 + <div class="text-sm text-neutral-600 dark:text-neutral-400"> 414 + {props.archive.entries.length} collection{props.archive.entries.length > 1 ? "s" : ""} 415 + <span class="text-neutral-400 dark:text-neutral-600"> ยท </span> 416 + {totalRecords()} record{totalRecords() > 1 ? "s" : ""} 417 + </div> 418 + 419 + <TextInput 420 + placeholder="Filter collections" 421 + value={filter()} 422 + onInput={(e) => setFilter(e.currentTarget.value)} 423 + class="text-sm" 424 + /> 425 + 426 + <ul class="flex flex-col"> 427 + <For each={filteredEntries()}> 428 + {(entry) => { 429 + const hasSingleEntry = entry.entries.length === 1; 430 + 431 + return ( 432 + <li> 433 + <button 434 + onClick={() => { 435 + if (hasSingleEntry) { 436 + props.onRoute({ 437 + type: "record", 438 + collection: entry, 439 + record: entry.entries[0], 440 + }); 441 + } else { 442 + props.onRoute({ type: "collection", collection: entry }); 443 + } 444 + }} 445 + 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" 446 + > 447 + <span 448 + class="truncate font-medium" 449 + classList={{ 450 + "text-neutral-700 dark:text-neutral-300": hasSingleEntry, 451 + "text-blue-400": !hasSingleEntry, 452 + }} 453 + > 454 + {entry.name} 455 + </span> 456 + 457 + <Show when={hasSingleEntry}> 458 + <span class="iconify lucide--chevron-right shrink-0 text-xs text-neutral-500" /> 459 + <span class="truncate font-medium text-blue-400">{entry.entries[0].key}</span> 460 + </Show> 461 + 462 + <Show when={!hasSingleEntry}> 463 + <span class="ml-auto text-xs text-neutral-500">{entry.entries.length}</span> 464 + </Show> 465 + </button> 466 + </li> 467 + ); 468 + }} 469 + </For> 470 + </ul> 471 + 472 + <Show when={filteredEntries().length === 0 && filter()}> 473 + <div class="flex flex-col items-center justify-center py-8 text-center"> 474 + <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" /> 475 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 476 + No collections match your filter 477 + </p> 478 + </div> 479 + </Show> 480 + </div> 481 + ); 482 + }; 483 + 484 + const RECORDS_PER_PAGE = 100; 485 + 486 + const CollectionSubview = (props: { 487 + archive: Archive; 488 + collection: CollectionEntry; 489 + onRoute: (view: View) => void; 490 + }) => { 491 + const [filter, setFilter] = createSignal(""); 492 + const [displayCount, setDisplayCount] = createSignal(RECORDS_PER_PAGE); 493 + 494 + // Sort entries by TID timestamp (most recent first), non-TID entries go to the end 495 + const sortedEntries = createMemo(() => { 496 + return [...props.collection.entries].sort((a, b) => { 497 + const aIsTid = TID.validate(a.key); 498 + const bIsTid = TID.validate(b.key); 499 + 500 + if (aIsTid && bIsTid) { 501 + return TID.parse(b.key).timestamp - TID.parse(a.key).timestamp; 502 + } 503 + if (aIsTid) return -1; 504 + if (bIsTid) return 1; 505 + return b.key.localeCompare(a.key); 506 + }); 507 + }); 508 + 509 + const filteredEntries = createMemo(() => { 510 + const f = filter().toLowerCase().trim(); 511 + if (!f) return sortedEntries(); 512 + return sortedEntries().filter((entry) => 513 + JSON.stringify(entry.record).toLowerCase().includes(f), 514 + ); 515 + }); 516 + 517 + const displayedEntries = createMemo(() => { 518 + return filteredEntries().slice(0, displayCount()); 519 + }); 520 + 521 + const hasMore = createMemo(() => filteredEntries().length > displayCount()); 522 + 523 + const loadMore = () => { 524 + setDisplayCount((prev) => prev + RECORDS_PER_PAGE); 525 + }; 526 + 527 + return ( 528 + <div class="flex flex-col gap-3"> 529 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 530 + {filteredEntries().length} record{filteredEntries().length > 1 ? "s" : ""} 531 + {filter() && filteredEntries().length !== props.collection.entries.length && ( 532 + <span class="text-neutral-400 dark:text-neutral-500"> 533 + {" "} 534 + (of {props.collection.entries.length}) 535 + </span> 536 + )} 537 + </span> 538 + 539 + <div class="flex items-center gap-2"> 540 + <TextInput 541 + placeholder="Filter records" 542 + value={filter()} 543 + onInput={(e) => { 544 + setFilter(e.currentTarget.value); 545 + setDisplayCount(RECORDS_PER_PAGE); 546 + }} 547 + class="grow text-sm" 548 + /> 549 + 550 + <Show when={hasMore()}> 551 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 552 + {displayedEntries().length}/{filteredEntries().length} 553 + </span> 554 + 555 + <Button onClick={loadMore}>Load More</Button> 556 + </Show> 557 + </div> 558 + 559 + <div class="flex flex-col font-mono"> 560 + <For each={displayedEntries()}> 561 + {(entry) => { 562 + const isTid = TID.validate(entry.key); 563 + const timestamp = isTid ? TID.parse(entry.key).timestamp / 1_000 : null; 564 + const [hover, setHover] = createSignal(false); 565 + const [previewHeight, setPreviewHeight] = createSignal(0); 566 + let rkeyRef!: HTMLButtonElement; 567 + let previewRef!: HTMLSpanElement; 568 + 569 + createEffect(() => { 570 + if (hover()) setPreviewHeight(previewRef.offsetHeight); 571 + }); 572 + 573 + const isOverflowing = (previewHeight: number) => 574 + rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; 575 + 576 + return ( 577 + <button 578 + onClick={() => { 579 + props.onRoute({ 580 + type: "record", 581 + collection: props.collection, 582 + record: entry, 583 + }); 584 + }} 585 + ref={rkeyRef} 586 + onmouseover={() => !isTouchDevice && setHover(true)} 587 + onmouseleave={() => !isTouchDevice && setHover(false)} 588 + 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" 589 + > 590 + <span class="shrink-0 text-sm text-blue-400">{entry.key}</span> 591 + <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 592 + {entry.cid} 593 + </span> 594 + <Show when={timestamp}> 595 + {(ts) => ( 596 + <span class="ml-auto shrink-0 text-xs">{localDateFromTimestamp(ts())}</span> 597 + )} 598 + </Show> 599 + <Show when={hover()}> 600 + <span 601 + ref={previewRef} 602 + 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"}`} 603 + > 604 + <JSONValue data={entry.record} repo={props.archive.did} truncate /> 605 + </span> 606 + </Show> 607 + </button> 608 + ); 609 + }} 610 + </For> 611 + </div> 612 + 613 + <Show when={filteredEntries().length === 0 && filter()}> 614 + <div class="flex flex-col items-center justify-center py-8 text-center"> 615 + <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" /> 616 + <p class="text-sm text-neutral-600 dark:text-neutral-400">No records match your filter</p> 617 + </div> 618 + </Show> 619 + </div> 620 + ); 621 + }; 622 + 623 + const RecordSubview = (props: { 624 + archive: Archive; 625 + collection: CollectionEntry; 626 + record: RecordEntry; 627 + }) => { 628 + return ( 629 + <div class="flex flex-col items-center gap-3"> 630 + <div class="flex w-full items-center gap-2 text-sm text-neutral-600 sm:text-base dark:text-neutral-400"> 631 + <span class="iconify lucide--box shrink-0" /> 632 + <span class="text-xs break-all">{props.record.cid}</span> 633 + </div> 634 + 635 + <Show 636 + when={props.record.record !== null} 637 + fallback={ 638 + <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"> 639 + Failed to decode record 640 + </div> 641 + } 642 + > 643 + <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"> 644 + <JSONValue data={props.record.record} repo={props.archive.did || ""} newTab /> 645 + </div> 646 + </Show> 647 + </div> 648 + ); 649 + };
+15 -1
src/components/notification.tsx
··· 7 7 progress?: number; 8 8 total?: number; 9 9 type?: "info" | "success" | "error"; 10 + onCancel?: () => void; 10 11 }; 11 12 12 13 const [notifications, setNotifications] = createStore<Notification[]>([]); ··· 48 49 "animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id), 49 50 "animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id), 50 51 }} 51 - onClick={() => removeNotification(notification.id)} 52 + onClick={() => 53 + notification.progress === undefined && removeNotification(notification.id) 54 + } 52 55 > 53 56 <div class="flex items-center gap-2 text-sm"> 54 57 <Show when={notification.progress !== undefined}> ··· 82 85 {notification.progress}% 83 86 </div> 84 87 </Show> 88 + <Show when={notification.onCancel}> 89 + <button 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 dark:bg-dark-300 mt-1 rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 + onClick={(e) => { 92 + e.stopPropagation(); 93 + notification.onCancel?.(); 94 + }} 95 + > 96 + Cancel 97 + </button> 98 + </Show> 85 99 </div> 86 100 </Show> 87 101 </div>
+44
src/views/car/index.tsx
··· 1 + import { Title } from "@solidjs/meta"; 2 + import { A } from "@solidjs/router"; 3 + 4 + export const CarView = () => { 5 + return ( 6 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 7 + <Title>Archive tools - PDSls</Title> 8 + <div class="flex flex-col gap-y-1"> 9 + <h1 class="text-lg font-semibold">Archive tools</h1> 10 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 11 + Tools for working with CAR (Content Addressable aRchive) files. 12 + </p> 13 + </div> 14 + 15 + <div class="flex flex-col gap-3"> 16 + <A 17 + href="explore" 18 + class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 19 + > 20 + <span class="iconify lucide--folder-search mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 21 + <div class="flex flex-col gap-1"> 22 + <span class="font-medium">Explore archive</span> 23 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 24 + Browse records inside a repository archive 25 + </span> 26 + </div> 27 + </A> 28 + 29 + <A 30 + href="unpack" 31 + class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 32 + > 33 + <span class="iconify lucide--file-archive mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-medium">Unpack archive</span> 36 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 37 + Extract records from an archive into a ZIP file 38 + </span> 39 + </div> 40 + </A> 41 + </div> 42 + </div> 43 + ); 44 + };
+152
src/views/car/logger.tsx
··· 1 + import { For } from "solid-js"; 2 + import { createMutable } from "solid-js/store"; 3 + 4 + interface LogEntry { 5 + type: "log" | "info" | "warn" | "error"; 6 + at: number; 7 + msg: string; 8 + } 9 + 10 + interface PendingLogEntry { 11 + msg: string; 12 + } 13 + 14 + export const createLogger = () => { 15 + const pending = createMutable<PendingLogEntry[]>([]); 16 + 17 + let backlog: LogEntry[] | undefined = []; 18 + let push = (entry: LogEntry) => { 19 + backlog!.push(entry); 20 + }; 21 + 22 + return { 23 + internal: { 24 + get pending() { 25 + return pending; 26 + }, 27 + attach(fn: (entry: LogEntry) => void) { 28 + if (backlog !== undefined) { 29 + for (let idx = 0, len = backlog.length; idx < len; idx++) { 30 + fn(backlog[idx]); 31 + } 32 + backlog = undefined; 33 + } 34 + push = fn; 35 + }, 36 + }, 37 + log(msg: string) { 38 + push({ type: "log", at: Date.now(), msg }); 39 + }, 40 + info(msg: string) { 41 + push({ type: "info", at: Date.now(), msg }); 42 + }, 43 + warn(msg: string) { 44 + push({ type: "warn", at: Date.now(), msg }); 45 + }, 46 + error(msg: string) { 47 + push({ type: "error", at: Date.now(), msg }); 48 + }, 49 + progress(initialMsg: string, throttleMs = 500) { 50 + pending.unshift({ msg: initialMsg }); 51 + 52 + let entry: PendingLogEntry | undefined = pending[0]; 53 + 54 + return { 55 + update: throttle((msg: string) => { 56 + if (entry !== undefined) { 57 + entry.msg = msg; 58 + } 59 + }, throttleMs), 60 + destroy() { 61 + if (entry !== undefined) { 62 + const index = pending.indexOf(entry); 63 + pending.splice(index, 1); 64 + entry = undefined; 65 + } 66 + }, 67 + [Symbol.dispose]() { 68 + this.destroy(); 69 + }, 70 + }; 71 + }, 72 + }; 73 + }; 74 + 75 + export type Logger = ReturnType<typeof createLogger>; 76 + 77 + const formatter = new Intl.DateTimeFormat("en-US", { timeStyle: "short", hour12: false }); 78 + 79 + export const LoggerView = (props: { logger: Logger }) => { 80 + return ( 81 + <ul class="flex flex-col font-mono text-xs empty:hidden"> 82 + <For each={props.logger.internal.pending}> 83 + {(entry) => ( 84 + <li class="flex gap-2 px-4 py-1 whitespace-pre-wrap"> 85 + <span class="shrink-0 font-medium whitespace-pre-wrap text-neutral-400">-----</span> 86 + <span class="wrap-break-word">{entry.msg}</span> 87 + </li> 88 + )} 89 + </For> 90 + 91 + <div 92 + ref={(node) => { 93 + props.logger.internal.attach(({ type, at, msg }) => { 94 + let ecn = `flex gap-2 whitespace-pre-wrap px-4 py-1`; 95 + let tcn = `shrink-0 whitespace-pre-wrap font-medium`; 96 + if (type === "log") { 97 + tcn += ` text-neutral-500`; 98 + } else if (type === "info") { 99 + ecn += ` bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`; 100 + tcn += ` text-blue-500`; 101 + } else if (type === "warn") { 102 + ecn += ` bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300`; 103 + tcn += ` text-amber-500`; 104 + } else if (type === "error") { 105 + ecn += ` bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`; 106 + tcn += ` text-red-500`; 107 + } 108 + 109 + const item = ( 110 + <li class={ecn}> 111 + <span class={tcn}>{formatter.format(at)}</span> 112 + <span class="wrap-break-word">{msg}</span> 113 + </li> 114 + ); 115 + 116 + if (item instanceof Node) { 117 + node.after(item); 118 + } 119 + }); 120 + }} 121 + /> 122 + </ul> 123 + ); 124 + }; 125 + 126 + const throttle = <T extends (...args: any[]) => void>(func: T, wait: number) => { 127 + let timeout: ReturnType<typeof setTimeout> | null = null; 128 + let lastArgs: Parameters<T> | null = null; 129 + let lastCallTime = 0; 130 + 131 + const invoke = () => { 132 + func(...lastArgs!); 133 + lastCallTime = Date.now(); 134 + timeout = null; 135 + }; 136 + 137 + return (...args: Parameters<T>) => { 138 + const now = Date.now(); 139 + const timeSinceLastCall = now - lastCallTime; 140 + 141 + lastArgs = args; 142 + 143 + if (timeSinceLastCall >= wait) { 144 + if (timeout !== null) { 145 + clearTimeout(timeout); 146 + } 147 + invoke(); 148 + } else if (timeout === null) { 149 + timeout = setTimeout(invoke, wait - timeSinceLastCall); 150 + } 151 + }; 152 + };
+146
src/views/car/shared.tsx
··· 1 + import * as CBOR from "@atcute/cbor"; 2 + import * as CID from "@atcute/cid"; 3 + import { A } from "@solidjs/router"; 4 + import { Show } from "solid-js"; 5 + import { type JSONType } from "../../components/json.jsx"; 6 + 7 + export const isIOS = 8 + /iPad|iPhone|iPod/.test(navigator.userAgent) || 9 + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 10 + 11 + // Convert CBOR-decoded objects to JSON-friendly format 12 + export const toJsonValue = (obj: unknown): JSONType => { 13 + if (obj === null || obj === undefined) return null; 14 + 15 + if (CID.isCidLink(obj)) { 16 + return { $link: obj.$link }; 17 + } 18 + 19 + if ( 20 + obj && 21 + typeof obj === "object" && 22 + "version" in obj && 23 + "codec" in obj && 24 + "digest" in obj && 25 + "bytes" in obj 26 + ) { 27 + try { 28 + return { $link: CID.toString(obj as CID.Cid) }; 29 + } catch {} 30 + } 31 + 32 + if (CBOR.isBytes(obj)) { 33 + return { $bytes: obj.$bytes }; 34 + } 35 + 36 + if (Array.isArray(obj)) { 37 + return obj.map(toJsonValue); 38 + } 39 + 40 + if (typeof obj === "object") { 41 + const result: Record<string, JSONType> = {}; 42 + for (const [key, value] of Object.entries(obj)) { 43 + result[key] = toJsonValue(value); 44 + } 45 + return result; 46 + } 47 + 48 + return obj as JSONType; 49 + }; 50 + 51 + export interface Archive { 52 + file: File; 53 + did: string; 54 + entries: CollectionEntry[]; 55 + } 56 + 57 + export interface CollectionEntry { 58 + name: string; 59 + entries: RecordEntry[]; 60 + } 61 + 62 + export interface RecordEntry { 63 + key: string; 64 + cid: string; 65 + record: JSONType; 66 + } 67 + 68 + export type View = 69 + | { type: "repo" } 70 + | { type: "collection"; collection: CollectionEntry } 71 + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 72 + 73 + export const WelcomeView = (props: { 74 + title: string; 75 + subtitle: string; 76 + loading: boolean; 77 + progress?: number; 78 + error?: string; 79 + onFileChange: (e: Event) => void; 80 + onDrop: (e: DragEvent) => void; 81 + onDragOver: (e: DragEvent) => void; 82 + }) => { 83 + return ( 84 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 85 + <div class="flex flex-col gap-y-1"> 86 + <div class="flex items-center gap-2 text-lg"> 87 + <A 88 + href="/car" 89 + class="flex size-7 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200" 90 + > 91 + <span class="iconify lucide--arrow-left" /> 92 + </A> 93 + <h1 class="font-semibold">{props.title}</h1> 94 + </div> 95 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{props.subtitle}</p> 96 + </div> 97 + 98 + <div 99 + 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" 100 + onDrop={props.onDrop} 101 + onDragOver={props.onDragOver} 102 + > 103 + <Show 104 + when={!props.loading} 105 + fallback={ 106 + <div class="flex flex-col items-center gap-2"> 107 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 108 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 109 + Reading CAR file... 110 + </span> 111 + <Show when={props.progress && props.progress > 0}> 112 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 113 + {props.progress?.toLocaleString()} records processed 114 + </span> 115 + </Show> 116 + </div> 117 + } 118 + > 119 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 120 + <div class="text-center"> 121 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 122 + Drag and drop a CAR file here 123 + </p> 124 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 + </div> 126 + <label class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-300 bg-neutral-50 px-2.5 py-1.5 text-sm text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300"> 127 + <input 128 + type="file" 129 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 130 + onChange={props.onFileChange} 131 + class="hidden" 132 + /> 133 + <span class="iconify lucide--upload text-sm" /> 134 + Choose file 135 + </label> 136 + </Show> 137 + </div> 138 + 139 + <Show when={props.error}> 140 + <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"> 141 + {props.error} 142 + </div> 143 + </Show> 144 + </div> 145 + ); 146 + };
+249
src/views/car/unpack.tsx
··· 1 + import { fromStream } from "@atcute/repo"; 2 + import { zip, type ZipEntry } from "@mary/zip"; 3 + import { Title } from "@solidjs/meta"; 4 + import { FileSystemWritableFileStream, showSaveFilePicker } from "native-file-system-adapter"; 5 + import { createSignal, onCleanup } from "solid-js"; 6 + import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js"; 7 + import { createLogger, LoggerView } from "./logger.jsx"; 8 + import { isIOS, toJsonValue, WelcomeView } from "./shared.jsx"; 9 + 10 + // Check if browser natively supports File System Access API 11 + const hasNativeFileSystemAccess = "showSaveFilePicker" in window; 12 + 13 + // HACK: Disable compression on WebKit due to an error being thrown 14 + const isWebKit = 15 + isIOS || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)); 16 + 17 + const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g; 18 + const filenamify = (name: string) => { 19 + return name.replace(INVALID_CHAR_RE, "~"); 20 + }; 21 + 22 + export const UnpackToolView = () => { 23 + const logger = createLogger(); 24 + const [pending, setPending] = createSignal(false); 25 + 26 + let abortController: AbortController | undefined; 27 + 28 + onCleanup(() => { 29 + abortController?.abort(); 30 + }); 31 + 32 + const unpackToZip = async (file: File) => { 33 + abortController?.abort(); 34 + abortController = new AbortController(); 35 + const signal = abortController.signal; 36 + 37 + setPending(true); 38 + logger.log(`Starting extraction`); 39 + 40 + let repo: Awaited<ReturnType<typeof fromStream>> | undefined; 41 + 42 + const stream = file.stream(); 43 + repo = fromStream(stream); 44 + 45 + try { 46 + let count = 0; 47 + 48 + // On Safari/browsers without native File System Access API, use blob download 49 + if (!hasNativeFileSystemAccess) { 50 + const chunks: BlobPart[] = []; 51 + 52 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 53 + const progress = logger.progress(`Unpacking records (0 entries)`); 54 + 55 + try { 56 + for await (const entry of repo) { 57 + if (signal.aborted) return; 58 + 59 + try { 60 + const record = toJsonValue(entry.record); 61 + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 62 + const data = JSON.stringify(record, null, 2); 63 + 64 + yield { filename, data, compress: isWebKit ? false : "deflate" }; 65 + count++; 66 + progress.update(`Unpacking records (${count} entries)`); 67 + } catch { 68 + // Skip entries with invalid data 69 + } 70 + } 71 + } finally { 72 + progress[Symbol.dispose]?.(); 73 + } 74 + }; 75 + 76 + for await (const chunk of zip(entryGenerator())) { 77 + if (signal.aborted) return; 78 + chunks.push(chunk as BlobPart); 79 + } 80 + 81 + if (signal.aborted) return; 82 + 83 + logger.log(`${count} records extracted`); 84 + logger.log(`Creating download...`); 85 + 86 + const blob = new Blob(chunks, { type: "application/zip" }); 87 + const url = URL.createObjectURL(blob); 88 + const a = document.createElement("a"); 89 + a.href = url; 90 + a.download = `${file.name.replace(/\.car$/, "")}.zip`; 91 + document.body.appendChild(a); 92 + a.click(); 93 + document.body.removeChild(a); 94 + URL.revokeObjectURL(url); 95 + 96 + logger.log(`Finished! Download started.`); 97 + setPending(false); 98 + return; 99 + } 100 + 101 + // Native File System Access API path 102 + let writable: FileSystemWritableFileStream | undefined; 103 + 104 + // Create async generator that yields ZipEntry as we read from CAR 105 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 106 + const progress = logger.progress(`Unpacking records (0 entries)`); 107 + 108 + try { 109 + for await (const entry of repo) { 110 + if (signal.aborted) return; 111 + 112 + // Prompt for save location on first record 113 + if (writable === undefined) { 114 + const waiting = logger.progress(`Waiting for user...`); 115 + 116 + try { 117 + const fd = await showSaveFilePicker({ 118 + suggestedName: `${file.name.replace(/\.car$/, "")}.zip`, 119 + // @ts-expect-error: ponyfill doesn't have full typings 120 + id: "car-unpack", 121 + startIn: "downloads", 122 + types: [ 123 + { 124 + description: "ZIP archive", 125 + accept: { "application/zip": [".zip"] }, 126 + }, 127 + ], 128 + }).catch((err) => { 129 + if (err instanceof DOMException && err.name === "AbortError") { 130 + logger.warn(`File picker was cancelled`); 131 + } else { 132 + logger.warn(`Something went wrong when opening the file picker`); 133 + } 134 + return undefined; 135 + }); 136 + 137 + if (!fd) { 138 + logger.warn(`No file handle obtained`); 139 + return; 140 + } 141 + 142 + writable = await fd.createWritable(); 143 + 144 + if (writable === undefined) { 145 + logger.warn(`Failed to create writable stream`); 146 + return; 147 + } 148 + } finally { 149 + waiting[Symbol.dispose]?.(); 150 + } 151 + } 152 + 153 + try { 154 + const record = toJsonValue(entry.record); 155 + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 156 + const data = JSON.stringify(record, null, 2); 157 + 158 + yield { filename, data, compress: isWebKit ? false : "deflate" }; 159 + count++; 160 + progress.update(`Unpacking records (${count} entries)`); 161 + } catch { 162 + // Skip entries with invalid data 163 + } 164 + } 165 + } finally { 166 + progress[Symbol.dispose]?.(); 167 + } 168 + }; 169 + 170 + // Stream entries directly to zip, then to file 171 + let writeCount = 0; 172 + for await (const chunk of zip(entryGenerator())) { 173 + if (signal.aborted) { 174 + await writable?.abort(); 175 + return; 176 + } 177 + if (writable === undefined) { 178 + // User cancelled file picker 179 + setPending(false); 180 + return; 181 + } 182 + writeCount++; 183 + // Await every 100th write to apply backpressure 184 + if (writeCount % 100 === 0) { 185 + await writable.write(chunk); 186 + } else { 187 + writable.write(chunk); // Fire and forget 188 + } 189 + } 190 + 191 + if (signal.aborted) return; 192 + 193 + if (writable === undefined) { 194 + logger.warn(`CAR file has no records`); 195 + setPending(false); 196 + return; 197 + } 198 + 199 + logger.log(`${count} records extracted`); 200 + 201 + { 202 + const flushProgress = logger.progress(`Flushing writes...`); 203 + try { 204 + await writable.close(); 205 + logger.log(`Finished! File saved successfully.`); 206 + } catch (err) { 207 + logger.error(`Failed to save file: ${err}`); 208 + throw err; // Re-throw to be caught by outer catch 209 + } finally { 210 + flushProgress[Symbol.dispose]?.(); 211 + } 212 + } 213 + } catch (err) { 214 + if (signal.aborted) return; 215 + logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`); 216 + } finally { 217 + await repo?.dispose(); 218 + if (!signal.aborted) { 219 + setPending(false); 220 + } 221 + } 222 + }; 223 + 224 + const handleFileChange = createFileChangeHandler(unpackToZip); 225 + 226 + // Wrap handleDrop to prevent multiple simultaneous uploads 227 + const baseDrop = createDropHandler(unpackToZip); 228 + const handleDrop = (e: DragEvent) => { 229 + if (pending()) return; 230 + baseDrop(e); 231 + }; 232 + 233 + return ( 234 + <> 235 + <Title>Unpack archive - PDSls</Title> 236 + <WelcomeView 237 + title="Unpack archive" 238 + subtitle="Upload a CAR file to extract all records into a ZIP archive." 239 + loading={pending()} 240 + onFileChange={handleFileChange} 241 + onDrop={handleDrop} 242 + onDragOver={handleDragOver} 243 + /> 244 + <div class="w-full max-w-3xl px-2"> 245 + <LoggerView logger={logger} /> 246 + </div> 247 + </> 248 + ); 249 + };
+24
src/views/car/file-handlers.ts
··· 1 + export const isCarFile = (file: File): boolean => { 2 + return file.name.endsWith(".car") || file.type === "application/vnd.ipld.car"; 3 + }; 4 + 5 + export const createFileChangeHandler = (onFile: (file: File) => void) => (e: Event) => { 6 + const input = e.target as HTMLInputElement; 7 + const file = input.files?.[0]; 8 + if (file) { 9 + onFile(file); 10 + } 11 + input.value = ""; 12 + }; 13 + 14 + export const createDropHandler = (onFile: (file: File) => void) => (e: DragEvent) => { 15 + e.preventDefault(); 16 + const file = e.dataTransfer?.files?.[0]; 17 + if (file && isCarFile(file)) { 18 + onFile(file); 19 + } 20 + }; 21 + 22 + export const handleDragOver = (e: DragEvent) => { 23 + e.preventDefault(); 24 + };
+108 -79
src/views/repo.tsx
··· 20 20 21 21 22 22 23 + MenuSeparator, 24 + NavMenu, 25 + } from "../components/dropdown.jsx"; 26 + import { Favicon } from "../components/favicon.jsx"; 27 + import { 28 + addNotification, 29 + removeNotification, 23 30 24 31 25 32 ··· 32 39 33 40 34 41 42 + import { detectDidKeyType, detectKeyType } from "../utils/key.js"; 43 + import { BlobView } from "./blob.jsx"; 44 + import { PlcLogView } from "./logs.jsx"; 45 + import { plcDirectory } from "./settings.jsx"; 35 46 47 + export const RepoView = () => { 48 + const params = useParams(); 36 49 37 50 38 51 ··· 86 99 87 100 88 101 102 + }; 89 103 104 + const getRotationKeys = async () => { 105 + const res = await fetch(`${plcDirectory()}/${did}/log/last`); 106 + const json = await res.json(); 107 + setRotationKeys(json.rotationKeys ?? []); 108 + }; 90 109 91 110 92 111 ··· 122 141 123 142 124 143 144 + if (!pds) { 145 + setError("Missing PDS"); 146 + return {}; 147 + } 125 148 126 149 127 150 ··· 295 318 296 319 297 320 321 + </div> 322 + <div class="flex gap-1"> 323 + <Show when={error() && error() !== "Missing PDS"}> 324 + <div class="flex items-center gap-1 font-medium text-red-500 dark:text-red-400"> 325 + <span class="iconify lucide--alert-triangle"></span> 326 + <span>{error()}</span> 327 + </div> 298 328 299 329 300 330 ··· 330 360 331 361 332 362 363 + <NavMenu 364 + href={ 365 + did.startsWith("did:plc") ? 366 + `${plcDirectory()}/${did}` 367 + : `https://${did.split("did:web:")[1]}/.well-known/did.json` 368 + } 369 + newTab 333 370 334 371 372 + /> 373 + <Show when={did.startsWith("did:plc")}> 374 + <NavMenu 375 + href={`${plcDirectory()}/${did}/log/audit`} 376 + newTab 377 + label="Audit log" 378 + icon="lucide--external-link" 335 379 336 380 337 381 ··· 406 450 407 451 408 452 453 + )} 454 + > 455 + {(authority) => { 456 + const isHighlighted = () => location.hash === `#collections:${authority}`; 409 457 458 + return ( 410 459 411 460 412 461 413 462 414 463 464 + "bg-blue-100 dark:bg-blue-500/25": isHighlighted(), 465 + }} 466 + > 467 + <Favicon 468 + authority={authority} 469 + wrapper={(children) => ( 470 + <a 471 + href={`#collections:${authority}`} 472 + class="relative flex h-5 w-4 shrink-0 items-center justify-center hover:opacity-70" 473 + > 474 + <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"> 475 + <span class="iconify lucide--link absolute -left-2 w-7"></span> 476 + </span> 477 + {children} 478 + </a> 479 + )} 480 + /> 481 + <div class="flex flex-1 flex-col"> 482 + <For 483 + each={nsids()?.[authority].nsids.filter((nsid) => 415 484 416 485 417 486 ··· 488 557 489 558 490 559 560 + {(service) => ( 561 + <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 562 + <span class="iconify lucide--hash"></span> 563 + <div class="flex items-center gap-2"> 564 + <span>{service.id.split("#")[1]}</span> 565 + <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 566 + <span 567 + class="iconify text-xs" 568 + classList={{ 569 + "lucide--hard-drive": 570 + service.type === "AtprotoPersonalDataServer", 571 + "lucide--tag": service.type === "AtprotoLabeler", 572 + "lucide--rss": service.type === "BskyFeedGenerator", 573 + "lucide--wrench": ![ 574 + "AtprotoPersonalDataServer", 575 + "AtprotoLabeler", 576 + "BskyFeedGenerator", 577 + ].includes(service.type.toString()), 578 + }} 579 + ></span> 580 + <span>{service.type}</span> 581 + </div> 582 + </div> 583 + <span></span> 584 + <a 585 + class="w-fit underline hover:text-blue-500 dark:hover:text-blue-400" 491 586 492 587 493 588 ··· 512 607 513 608 514 609 610 + <div class="flex items-center gap-2"> 611 + <span>{verif.id.split("#")[1]}</span> 612 + <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 613 + <span class="iconify lucide--key-round text-xs"></span> 614 + <span>{detectKeyType(key())}</span> 615 + </div> 616 + </div> 515 617 516 618 517 619 ··· 528 630 529 631 530 632 531 - 532 - 533 - 534 - 535 - 536 - 537 - 538 - 539 - 540 - 541 - 542 - 543 - 544 - 545 - 546 - 547 - 548 - 549 - 550 - 551 - 552 - 553 - 554 - 555 - 556 - 557 - 558 - 559 - 560 - 561 - 562 - 563 - 564 - 565 - 566 - 567 - 568 - 569 - 570 - 571 - 572 - 573 - 574 - {(service) => ( 633 + <For each={rotationKeys()}> 634 + {(key) => ( 575 635 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 576 - <span class="iconify lucide--hash"></span> 577 - <span>{service.id.split("#")[1]}</span> 636 + <span class="iconify lucide--key-round"></span> 637 + <span>{detectDidKeyType(key)}</span> 578 638 <span></span> 579 - <a 580 - class="w-fit underline hover:text-blue-500 dark:hover:text-blue-400" 581 - 582 - 583 - 584 - 585 - 586 - 587 - 588 - 589 - 590 - 591 - 592 - 593 - 594 - 595 - 596 - 597 - 598 - 599 - 600 - 601 - 602 - 603 - 604 - 605 - <div class="flex items-center gap-2"> 606 - <span>{verif.id.split("#")[1]}</span> 607 - <div class="flex items-center gap-1 text-neutral-500 dark:text-neutral-400"> 608 - <span class="iconify lucide--key-round"></span> 609 - <span>{detectKeyType(key())}</span> 610 - </div> 611 - </div> 639 + <div class="font-mono break-all">{key.replace("did:key:", "")}</div> 640 + </div>
public/fonts/Figtree[wght].woff2 public/fonts/Figtree.woff2
+2
src/auth/state.ts
··· 12 12 13 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 + export const [openManager, setOpenManager] = createSignal(false); 16 + export const [pendingPermissionEdit, setPendingPermissionEdit] = createSignal<string | null>(null);
+36
src/components/favicon.tsx
··· 1 + import { createSignal, JSX, Match, Show, Switch } from "solid-js"; 2 + 3 + export const Favicon = (props: { 4 + authority: string; 5 + wrapper?: (children: JSX.Element) => JSX.Element; 6 + }) => { 7 + const [loaded, setLoaded] = createSignal(false); 8 + const domain = () => props.authority.split(".").reverse().join("."); 9 + 10 + const content = ( 11 + <Switch> 12 + <Match when={domain() === "tangled.sh"}> 13 + <span class="iconify i-tangled size-4" /> 14 + </Match> 15 + <Match when={["bsky.app", "bsky.chat"].includes(domain())}> 16 + <img src="https://web-cdn.bsky.app/static/apple-touch-icon.png" class="size-4" /> 17 + </Match> 18 + <Match when={true}> 19 + <Show when={!loaded()}> 20 + <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 21 + </Show> 22 + <img 23 + src={`https://${domain()}/favicon.ico`} 24 + class="size-4" 25 + classList={{ hidden: !loaded() }} 26 + onLoad={() => setLoaded(true)} 27 + onError={() => setLoaded(false)} 28 + /> 29 + </Match> 30 + </Switch> 31 + ); 32 + 33 + return props.wrapper ? 34 + props.wrapper(content) 35 + : <div class="flex h-5 w-4 shrink-0 items-center justify-center">{content}</div>; 36 + };
+8 -3
src/auth/oauth-config.ts
··· 1 - import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client"; 1 + import { LocalActorResolver } from "@atcute/identity-resolver"; 2 + import { configureOAuth } from "@atcute/oauth-browser-client"; 2 3 import { didDocumentResolver, handleResolver } from "../utils/api"; 3 4 5 + const reactiveDidDocumentResolver = { 6 + resolve: async (did: string) => didDocumentResolver().resolve(did as any), 7 + }; 8 + 4 9 configureOAuth({ 5 10 metadata: { 6 11 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 7 12 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 8 13 }, 9 - identityResolver: defaultIdentityResolver({ 14 + identityResolver: new LocalActorResolver({ 10 15 handleResolver: handleResolver, 11 - didDocumentResolver: didDocumentResolver, 16 + didDocumentResolver: reactiveDidDocumentResolver, 12 17 }), 13 18 });
+22 -14
src/utils/api.ts
··· 16 16 import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver"; 17 17 import { Did, Handle } from "@atcute/lexicons"; 18 18 import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; 19 + import { createMemo } from "solid-js"; 19 20 import { createStore } from "solid-js/store"; 20 21 import { setPDS } from "../components/navbar"; 21 - 22 - export const didDocumentResolver = new CompositeDidDocumentResolver({ 23 - methods: { 24 - plc: new PlcDidDocumentResolver({ 25 - apiUrl: localStorage.getItem("plcDirectory") ?? "https://plc.directory", 22 + import { plcDirectory } from "../views/settings"; 23 + 24 + export const didDocumentResolver = createMemo( 25 + () => 26 + new CompositeDidDocumentResolver({ 27 + methods: { 28 + plc: new PlcDidDocumentResolver({ 29 + apiUrl: plcDirectory(), 30 + }), 31 + web: new AtprotoWebDidDocumentResolver(), 32 + }, 26 33 }), 27 - web: new AtprotoWebDidDocumentResolver(), 28 - }, 29 - }); 34 + ); 30 35 31 36 export const handleResolver = new CompositeHandleResolver({ 32 37 strategy: "dns-first", ··· 40 45 dohUrl: "https://dns.google/resolve?", 41 46 }); 42 47 43 - const schemaResolver = new LexiconSchemaResolver({ 44 - didDocumentResolver: didDocumentResolver, 45 - }); 48 + const schemaResolver = createMemo( 49 + () => 50 + new LexiconSchemaResolver({ 51 + didDocumentResolver: didDocumentResolver(), 52 + }), 53 + ); 46 54 47 55 const didPDSCache: Record<string, string> = {}; 48 56 const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({}); ··· 54 62 throw new Error("Not a valid DID identifier"); 55 63 } 56 64 57 - const doc = await didDocumentResolver.resolve(did); 65 + const doc = await didDocumentResolver().resolve(did); 58 66 didDocCache[did] = doc; 59 67 60 68 const pds = getPdsEndpoint(doc); ··· 83 91 if (!isAtprotoDid(did)) { 84 92 throw new Error("Not a valid DID identifier"); 85 93 } 86 - return await didDocumentResolver.resolve(did); 94 + return await didDocumentResolver().resolve(did); 87 95 }; 88 96 89 97 const validateHandle = async (handle: Handle, did: Did) => { ··· 145 153 }; 146 154 147 155 const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { 148 - return await schemaResolver.resolve(authority, nsid); 156 + return await schemaResolver().resolve(authority, nsid); 149 157 }; 150 158 151 159 interface LinkData {
+1 -1
LICENSE
··· 1 - Copyright (c) 2024-2025 Juliet Philippe <m@juli.ee> 1 + Copyright (c) 2024-2026 Juliet Philippe <m@juli.ee> 2 2 3 3 Permission to use, copy, modify, and/or distribute this software for any 4 4 purpose with or without fee is hereby granted.
+2 -1
README.md
··· 1 - # PDSls - AT Protocol Explorer 1 + # PDSls - Atmosphere Explorer 2 2 3 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 4 ··· 9 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 11 - Query moderation labels. 12 + - Explore and unpack repository archives (CAR). 12 13 13 14 ## Hacking 14 15
+9
src/utils/format.ts
··· 1 + const formatFileSize = (bytes: number): string => { 2 + if (bytes === 0) return "0 B"; 3 + const k = 1024; 4 + const sizes = ["B", "KB", "MB", "GB"]; 5 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 6 + return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`; 7 + }; 8 + 9 + export { formatFileSize };
+130
src/views/stream/stats.tsx
··· 1 + import { For, Show } from "solid-js"; 2 + import { STREAM_CONFIGS, StreamType } from "./config"; 3 + 4 + export type StreamStats = { 5 + connectedAt?: number; 6 + totalEvents: number; 7 + eventsPerSecond: number; 8 + eventTypes: Record<string, number>; 9 + collections: Record<string, number>; 10 + }; 11 + 12 + const formatUptime = (ms: number) => { 13 + const seconds = Math.floor(ms / 1000); 14 + const minutes = Math.floor(seconds / 60); 15 + const hours = Math.floor(minutes / 60); 16 + 17 + if (hours > 0) { 18 + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; 19 + } else if (minutes > 0) { 20 + return `${minutes}m ${seconds % 60}s`; 21 + } else { 22 + return `${seconds}s`; 23 + } 24 + }; 25 + 26 + export const StreamStatsPanel = (props: { 27 + stats: StreamStats; 28 + currentTime: number; 29 + streamType: StreamType; 30 + showAllEvents?: boolean; 31 + }) => { 32 + const config = () => STREAM_CONFIGS[props.streamType]; 33 + const uptime = () => (props.stats.connectedAt ? props.currentTime - props.stats.connectedAt : 0); 34 + 35 + const shouldShowEventTypes = () => { 36 + if (!config().showEventTypes) return false; 37 + if (props.streamType === "jetstream") return props.showAllEvents === true; 38 + return true; 39 + }; 40 + 41 + const topCollections = () => 42 + Object.entries(props.stats.collections) 43 + .sort(([, a], [, b]) => b - a) 44 + .slice(0, 5); 45 + 46 + const topEventTypes = () => 47 + Object.entries(props.stats.eventTypes) 48 + .sort(([, a], [, b]) => b - a) 49 + .slice(0, 5); 50 + 51 + return ( 52 + <Show when={props.stats.connectedAt !== undefined}> 53 + <div class="w-full text-sm"> 54 + <div class="mb-1 font-semibold">Statistics</div> 55 + <div class="flex flex-wrap justify-between gap-x-4 gap-y-2"> 56 + <div> 57 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Uptime</div> 58 + <div class="font-mono">{formatUptime(uptime())}</div> 59 + </div> 60 + <div> 61 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Total Events</div> 62 + <div class="font-mono">{props.stats.totalEvents.toLocaleString()}</div> 63 + </div> 64 + <div> 65 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Events/sec</div> 66 + <div class="font-mono">{props.stats.eventsPerSecond.toFixed(1)}</div> 67 + </div> 68 + <div> 69 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Avg/sec</div> 70 + <div class="font-mono"> 71 + {uptime() > 0 ? ((props.stats.totalEvents / uptime()) * 1000).toFixed(1) : "0.0"} 72 + </div> 73 + </div> 74 + </div> 75 + 76 + <Show when={topEventTypes().length > 0 && shouldShowEventTypes()}> 77 + <div class="mt-2"> 78 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Event Types</div> 79 + <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> 80 + <For each={topEventTypes()}> 81 + {([type, count]) => { 82 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 83 + return ( 84 + <> 85 + <span class="text-neutral-700 dark:text-neutral-300">{type}</span> 86 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 87 + {count.toLocaleString()} 88 + </span> 89 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 90 + {percentage}% 91 + </span> 92 + </> 93 + ); 94 + }} 95 + </For> 96 + </div> 97 + </div> 98 + </Show> 99 + 100 + <Show when={topCollections().length > 0}> 101 + <div class="mt-2"> 102 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400"> 103 + {config().collectionsLabel} 104 + </div> 105 + <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> 106 + <For each={topCollections()}> 107 + {([collection, count]) => { 108 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 109 + return ( 110 + <> 111 + <span class="min-w-0 truncate text-neutral-700 dark:text-neutral-300"> 112 + {collection} 113 + </span> 114 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 115 + {count.toLocaleString()} 116 + </span> 117 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 118 + {percentage}% 119 + </span> 120 + </> 121 + ); 122 + }} 123 + </For> 124 + </div> 125 + </div> 126 + </Show> 127 + </div> 128 + </Show> 129 + ); 130 + };
+130
src/components/hover-card/base.tsx
··· 1 + import { A } from "@solidjs/router"; 2 + import { createSignal, JSX, onCleanup, Show } from "solid-js"; 3 + import { Portal } from "solid-js/web"; 4 + import { isTouchDevice } from "../../layout"; 5 + 6 + interface HoverCardProps { 7 + /** Link href - if provided, renders an A tag */ 8 + href?: string; 9 + /** Link/trigger label text */ 10 + label?: string; 11 + /** Open link in new tab */ 12 + newTab?: boolean; 13 + /** Called when hover starts (for prefetching) */ 14 + onHover?: () => void; 15 + /** Delay in ms before showing card and calling onHover (default: 0) */ 16 + hoverDelay?: number; 17 + /** Custom trigger element - if provided, overrides href/label */ 18 + trigger?: JSX.Element; 19 + /** Additional classes for the wrapper span */ 20 + class?: string; 21 + /** Additional classes for the link/label */ 22 + labelClass?: string; 23 + /** Additional classes for the preview container */ 24 + previewClass?: string; 25 + /** Preview content */ 26 + children: JSX.Element; 27 + } 28 + 29 + const HoverCard = (props: HoverCardProps) => { 30 + const [show, setShow] = createSignal(false); 31 + 32 + const [previewHeight, setPreviewHeight] = createSignal(0); 33 + const [anchorRect, setAnchorRect] = createSignal<DOMRect | null>(null); 34 + let anchorRef!: HTMLSpanElement; 35 + let previewRef!: HTMLDivElement; 36 + let resizeObserver: ResizeObserver | null = null; 37 + let hoverTimeout: number | null = null; 38 + 39 + const setupResizeObserver = (el: HTMLDivElement) => { 40 + resizeObserver?.disconnect(); 41 + previewRef = el; 42 + resizeObserver = new ResizeObserver(() => { 43 + if (previewRef) setPreviewHeight(previewRef.offsetHeight); 44 + }); 45 + resizeObserver.observe(el); 46 + }; 47 + 48 + onCleanup(() => { 49 + resizeObserver?.disconnect(); 50 + if (hoverTimeout !== null) { 51 + clearTimeout(hoverTimeout); 52 + } 53 + }); 54 + 55 + const isOverflowing = (previewHeight: number) => { 56 + const rect = anchorRect(); 57 + return rect && rect.top + previewHeight + 32 > window.innerHeight; 58 + }; 59 + 60 + const getPreviewStyle = () => { 61 + const rect = anchorRect(); 62 + if (!rect) return {}; 63 + 64 + const left = rect.left + rect.width / 2; 65 + const overflowing = isOverflowing(previewHeight()); 66 + const gap = 4; 67 + 68 + return { 69 + left: `${left}px`, 70 + top: overflowing ? `${rect.top - gap}px` : `${rect.bottom + gap}px`, 71 + transform: overflowing ? "translate(-50%, -100%)" : "translate(-50%, 0)", 72 + }; 73 + }; 74 + 75 + const handleMouseEnter = () => { 76 + const delay = props.hoverDelay ?? 0; 77 + setAnchorRect(anchorRef.getBoundingClientRect()); 78 + 79 + if (delay > 0) { 80 + hoverTimeout = window.setTimeout(() => { 81 + props.onHover?.(); 82 + setShow(true); 83 + hoverTimeout = null; 84 + }, delay); 85 + } else { 86 + props.onHover?.(); 87 + setShow(true); 88 + } 89 + }; 90 + 91 + const handleMouseLeave = () => { 92 + if (hoverTimeout !== null) { 93 + clearTimeout(hoverTimeout); 94 + hoverTimeout = null; 95 + } 96 + setShow(false); 97 + }; 98 + 99 + return ( 100 + <span 101 + ref={anchorRef} 102 + class={`group/hover-card relative ${props.class || "inline"}`} 103 + onMouseEnter={handleMouseEnter} 104 + onMouseLeave={handleMouseLeave} 105 + > 106 + {props.trigger ?? ( 107 + <A 108 + class={`text-blue-500 hover:underline active:underline dark:text-blue-400 ${props.labelClass || ""}`} 109 + href={props.href!} 110 + target={props.newTab ? "_blank" : undefined} 111 + > 112 + {props.label} 113 + </A> 114 + )} 115 + <Show when={show() && !isTouchDevice}> 116 + <Portal> 117 + <div 118 + ref={setupResizeObserver} 119 + style={getPreviewStyle()} 120 + class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none fixed z-50 block overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700 ${props.previewClass ?? "max-h-80 w-max max-w-sm font-mono text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg"}`} 121 + > 122 + {props.children} 123 + </div> 124 + </Portal> 125 + </Show> 126 + </span> 127 + ); 128 + }; 129 + 130 + export default HoverCard;
+108
src/components/hover-card/did.tsx
··· 1 + import { getPdsEndpoint, type DidDocument } from "@atcute/identity"; 2 + import { createSignal, Show } from "solid-js"; 3 + import { resolveDidDoc } from "../../utils/api"; 4 + import HoverCard from "./base"; 5 + 6 + interface DidHoverCardProps { 7 + did: string; 8 + newTab?: boolean; 9 + class?: string; 10 + labelClass?: string; 11 + trigger?: any; 12 + hoverDelay?: number; 13 + } 14 + 15 + interface DidInfo { 16 + handle?: string; 17 + pds?: string; 18 + loading: boolean; 19 + error?: string; 20 + } 21 + 22 + const didCache = new Map<string, DidInfo>(); 23 + 24 + const prefetchDid = async (did: string) => { 25 + if (didCache.has(did)) return; 26 + 27 + didCache.set(did, { loading: true }); 28 + 29 + try { 30 + const doc: DidDocument = await resolveDidDoc(did as `did:${string}:${string}`); 31 + 32 + const handle = doc.alsoKnownAs?.find((aka) => aka.startsWith("at://"))?.replace("at://", ""); 33 + 34 + const pds = getPdsEndpoint(doc)?.replace("https://", "").replace("http://", ""); 35 + 36 + didCache.set(did, { handle, pds, loading: false }); 37 + } catch (err: any) { 38 + didCache.set(did, { loading: false, error: err.message || "Failed to resolve" }); 39 + } 40 + }; 41 + 42 + const DidHoverCard = (props: DidHoverCardProps) => { 43 + const [didInfo, setDidInfo] = createSignal<DidInfo | null>(null); 44 + 45 + const handlePrefetch = () => { 46 + prefetchDid(props.did); 47 + 48 + const cached = didCache.get(props.did); 49 + setDidInfo(cached || { loading: true }); 50 + 51 + if (!cached || cached.loading) { 52 + const pollInterval = setInterval(() => { 53 + const updated = didCache.get(props.did); 54 + if (updated && !updated.loading) { 55 + setDidInfo(updated); 56 + clearInterval(pollInterval); 57 + } 58 + }, 100); 59 + 60 + setTimeout(() => clearInterval(pollInterval), 10000); 61 + } 62 + }; 63 + 64 + return ( 65 + <HoverCard 66 + href={`/at://${props.did}`} 67 + label={props.did} 68 + newTab={props.newTab} 69 + onHover={handlePrefetch} 70 + hoverDelay={props.hoverDelay ?? 300} 71 + trigger={props.trigger} 72 + class={props.class} 73 + labelClass={props.labelClass} 74 + previewClass="w-max max-w-xs font-sans text-sm" 75 + > 76 + <Show when={didInfo()?.loading}> 77 + <div class="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"> 78 + <span class="iconify lucide--loader-circle animate-spin" /> 79 + Loading... 80 + </div> 81 + </Show> 82 + <Show when={didInfo()?.error}> 83 + <div class="text-sm text-red-500 dark:text-red-400">{didInfo()?.error}</div> 84 + </Show> 85 + <Show when={!didInfo()?.loading && !didInfo()?.error}> 86 + <div class="flex flex-col gap-1"> 87 + <Show when={didInfo()?.handle}> 88 + <div class="flex items-center gap-2"> 89 + <span class="iconify lucide--at-sign text-neutral-500 dark:text-neutral-400" /> 90 + <span>{didInfo()?.handle}</span> 91 + </div> 92 + </Show> 93 + <Show when={didInfo()?.pds}> 94 + <div class="flex items-center gap-2"> 95 + <span class="iconify lucide--hard-drive text-neutral-500 dark:text-neutral-400" /> 96 + <span>{didInfo()?.pds}</span> 97 + </div> 98 + </Show> 99 + <Show when={!didInfo()?.handle && !didInfo()?.pds}> 100 + <div class="text-neutral-500 dark:text-neutral-400">No info available</div> 101 + </Show> 102 + </div> 103 + </Show> 104 + </HoverCard> 105 + ); 106 + }; 107 + 108 + export default DidHoverCard;
+119
src/components/hover-card/record.tsx
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { ActorIdentifier } from "@atcute/lexicons"; 3 + import { createSignal, Show } from "solid-js"; 4 + import { getPDS } from "../../utils/api"; 5 + import { JSONValue } from "../json"; 6 + import HoverCard from "./base"; 7 + 8 + interface RecordHoverCardProps { 9 + uri: string; 10 + newTab?: boolean; 11 + class?: string; 12 + labelClass?: string; 13 + trigger?: any; 14 + hoverDelay?: number; 15 + } 16 + 17 + const recordCache = new Map<string, { value: unknown; loading: boolean; error?: string }>(); 18 + 19 + const parseAtUri = (uri: string) => { 20 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 21 + if (!match) return null; 22 + return { repo: match[1], collection: match[2], rkey: match[3] }; 23 + }; 24 + 25 + const prefetchRecord = async (uri: string) => { 26 + if (recordCache.has(uri)) return; 27 + 28 + const parsed = parseAtUri(uri); 29 + if (!parsed) return; 30 + 31 + recordCache.set(uri, { value: null, loading: true }); 32 + 33 + try { 34 + const pds = await getPDS(parsed.repo); 35 + const rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 36 + const res = await rpc.get("com.atproto.repo.getRecord", { 37 + params: { 38 + repo: parsed.repo as ActorIdentifier, 39 + collection: parsed.collection as `${string}.${string}.${string}`, 40 + rkey: parsed.rkey, 41 + }, 42 + }); 43 + 44 + if (!res.ok) { 45 + recordCache.set(uri, { value: null, loading: false, error: res.data.error }); 46 + return; 47 + } 48 + 49 + recordCache.set(uri, { value: res.data.value, loading: false }); 50 + } catch (err: any) { 51 + recordCache.set(uri, { value: null, loading: false, error: err.message || "Failed to fetch" }); 52 + } 53 + }; 54 + 55 + const RecordHoverCard = (props: RecordHoverCardProps) => { 56 + const [record, setRecord] = createSignal<{ 57 + value: unknown; 58 + loading: boolean; 59 + error?: string; 60 + } | null>(null); 61 + 62 + const parsed = () => parseAtUri(props.uri); 63 + 64 + const handlePrefetch = () => { 65 + prefetchRecord(props.uri); 66 + 67 + // Start polling for cache updates 68 + const cached = recordCache.get(props.uri); 69 + setRecord(cached || { value: null, loading: true }); 70 + 71 + if (!cached || cached.loading) { 72 + const pollInterval = setInterval(() => { 73 + const updated = recordCache.get(props.uri); 74 + if (updated && !updated.loading) { 75 + setRecord(updated); 76 + clearInterval(pollInterval); 77 + } 78 + }, 100); 79 + 80 + setTimeout(() => clearInterval(pollInterval), 10000); 81 + } 82 + }; 83 + 84 + return ( 85 + <HoverCard 86 + href={`/${props.uri}`} 87 + label={props.uri} 88 + newTab={props.newTab} 89 + onHover={handlePrefetch} 90 + hoverDelay={props.hoverDelay ?? 300} 91 + trigger={props.trigger} 92 + class={props.class} 93 + labelClass={props.labelClass} 94 + > 95 + <Show when={record()?.loading}> 96 + <div class="flex items-center gap-2 font-sans text-sm text-neutral-500 dark:text-neutral-400"> 97 + <span class="iconify lucide--loader-circle animate-spin" /> 98 + Loading... 99 + </div> 100 + </Show> 101 + <Show when={record()?.error}> 102 + <div class="font-sans text-sm text-red-500 dark:text-red-400">{record()?.error}</div> 103 + </Show> 104 + <Show when={record()?.value && !record()?.loading}> 105 + <div class="font-mono text-xs wrap-break-word"> 106 + <JSONValue 107 + data={record()?.value as any} 108 + repo={parsed()?.repo || ""} 109 + truncate 110 + newTab 111 + hideBlobs 112 + /> 113 + </div> 114 + </Show> 115 + </HoverCard> 116 + ); 117 + }; 118 + 119 + export default RecordHoverCard;
+7 -8
src/views/car/explore.tsx
··· 528 528 529 529 530 530 531 - 532 - 533 - 534 - 535 - 536 - 537 - 531 + }} 532 + class="flex w-full items-baseline gap-1 text-left" 533 + > 534 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 535 + {entry.key} 536 + </span> 537 + <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 538 538 539 539 540 540 ··· 544 544 </Show> 545 545 </button> 546 546 } 547 - previewClass="max-h-80 w-max max-w-sm text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg" 548 547 > 549 548 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 550 549 </HoverCard>
+126 -2
src/auth/account.tsx
··· 1 1 import { Did } from "@atcute/lexicons"; 2 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 3 import { A } from "@solidjs/router"; 4 - import { createSignal, For, onMount, Show } from "solid-js"; 4 + import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 5 5 import { createStore, produce } from "solid-js/store"; 6 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 7 import { Modal } from "../components/modal.jsx"; ··· 17 17 retrieveSession, 18 18 saveSessionToStorage, 19 19 } from "./session-manager.js"; 20 - import { agent, openManager, sessions, setAgent, setOpenManager, setSessions } from "./state.js"; 20 + import { 21 + agent, 22 + openManager, 23 + pendingPermissionEdit, 24 + sessions, 25 + setAgent, 26 + setOpenManager, 27 + setPendingPermissionEdit, 28 + setSessions, 29 + } from "./state.js"; 21 30 22 31 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 32 const removeSession = async (did: Did) => { ··· 73 82 beforeRedirect: (account) => resumeSession(account as Did), 74 83 }); 75 84 85 + createEffect(() => { 86 + const pending = pendingPermissionEdit(); 87 + if (pending) { 88 + scopeFlow.initiateWithRedirect(pending); 89 + setPendingPermissionEdit(null); 90 + } 91 + }); 92 + 76 93 const handleAccountClick = async (did: Did) => { 77 94 try { 78 95 await resumeSession(did); 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + setShowingAddAccount(false); 126 + scopeFlow.cancel(); 127 + }} 128 + alignTop 129 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-full max-w-sm rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 mx-3 shadow-md dark:border-neutral-700" 130 + > 131 + <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 132 + <div class="mb-2 px-1 font-semibold"> 133 + <span>Switch account</span> 134 + </div> 135 + <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 136 + <For each={Object.keys(sessions)}> 137 + {(did) => ( 138 + <div class="flex w-full items-center justify-between"> 139 + <A 140 + href={`/at://${did}`} 141 + onClick={() => setOpenManager(false)} 142 + class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 143 + > 144 + <Show 145 + when={avatars[did as Did]} 146 + fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 147 + > 148 + <img src={getThumbnailUrl(avatars[did as Did])} class="size-6 rounded-full" /> 149 + </Show> 150 + </A> 151 + <button 152 + class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 153 + onclick={() => handleAccountClick(did as Did)} 154 + > 155 + <span class="truncate">{sessions[did]?.handle || did}</span> 156 + <Show when={did === agent()?.sub && sessions[did].signedIn}> 157 + <span class="iconify lucide--circle-check shrink-0 text-blue-500 dark:text-blue-400"></span> 158 + </Show> 159 + <Show when={!sessions[did].signedIn}> 160 + <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 161 + </Show> 162 + </button> 163 + <AccountDropdown 164 + did={did as Did} 165 + onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 166 + /> 167 + </div> 168 + )} 169 + </For> 170 + </div> 171 + <button 172 + onclick={() => setShowingAddAccount(true)} 173 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 174 + > 175 + <span class="iconify lucide--plus"></span> 176 + <span>Add account</span> 177 + </button> 178 + </Show> 179 + 180 + <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 181 + <Login onCancel={() => setShowingAddAccount(false)} /> 182 + </Show> 183 + 184 + <Show when={scopeFlow.showScopeSelector()}> 185 + <ScopeSelector 186 + initialScopes={parseScopeString( 187 + sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 188 + )} 189 + onConfirm={scopeFlow.complete} 190 + onCancel={() => { 191 + scopeFlow.cancel(); 192 + setShowingAddAccount(false); 193 + }} 194 + /> 195 + </Show> 196 + </Modal> 197 + <button 198 + onclick={() => setOpenManager(true)} 199 + class={`flex items-center rounded-md ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 200 + > 201 + {agent() && avatars[agent()!.sub] ? 202 + <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" />
+38
src/components/permission-button.tsx
··· 1 + import { JSX } from "solid-js"; 2 + import { hasUserScope } from "../auth/scope-utils"; 3 + import { showPermissionPrompt } from "./permission-prompt"; 4 + import Tooltip from "./tooltip"; 5 + 6 + export interface PermissionButtonProps { 7 + scope: "create" | "update" | "delete" | "blob"; 8 + tooltip: string; 9 + class?: string; 10 + disabledClass?: string; 11 + onClick: () => void; 12 + children: JSX.Element; 13 + } 14 + 15 + export const PermissionButton = (props: PermissionButtonProps) => { 16 + const hasPermission = () => hasUserScope(props.scope); 17 + 18 + const handleClick = () => { 19 + if (hasPermission()) { 20 + props.onClick(); 21 + } else { 22 + showPermissionPrompt(props.scope); 23 + } 24 + }; 25 + 26 + const baseClass = 27 + props.class || 28 + "flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"; 29 + const disabledClass = props.disabledClass || "flex items-center rounded-sm p-1.5 opacity-40"; 30 + 31 + return ( 32 + <Tooltip text={hasPermission() ? props.tooltip : `${props.tooltip} (permission required)`}> 33 + <button class={hasPermission() ? baseClass : disabledClass} onclick={handleClick}> 34 + {props.children} 35 + </button> 36 + </Tooltip> 37 + ); 38 + };
+52
src/components/permission-prompt.tsx
··· 1 + import { createSignal } from "solid-js"; 2 + import { GRANULAR_SCOPES } from "../auth/scope-utils"; 3 + import { agent, setOpenManager, setPendingPermissionEdit } from "../auth/state"; 4 + import { Button } from "./button"; 5 + import { Modal } from "./modal"; 6 + 7 + type ScopeId = "create" | "update" | "delete" | "blob"; 8 + 9 + const [requestedScope, setRequestedScope] = createSignal<ScopeId | null>(null); 10 + 11 + export const showPermissionPrompt = (scope: ScopeId) => { 12 + setRequestedScope(scope); 13 + }; 14 + 15 + export const PermissionPromptContainer = () => { 16 + const scopeLabel = () => { 17 + const scope = GRANULAR_SCOPES.find((s) => s.id === requestedScope()); 18 + return scope?.label.toLowerCase() || requestedScope(); 19 + }; 20 + 21 + const handleEditPermissions = () => { 22 + setRequestedScope(null); 23 + if (agent()) { 24 + setPendingPermissionEdit(agent()!.sub); 25 + setOpenManager(true); 26 + } 27 + }; 28 + 29 + return ( 30 + <Modal 31 + open={requestedScope() !== null} 32 + onClose={() => setRequestedScope(null)} 33 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[calc(100%-2rem)] max-w-md rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 34 + > 35 + <h2 class="mb-2 font-semibold">Permission required</h2> 36 + <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400"> 37 + You need the "{scopeLabel()}" permission to perform this action. 38 + </p> 39 + <div class="flex justify-end gap-2"> 40 + <Button onClick={() => setRequestedScope(null)}>Cancel</Button> 41 + <Button 42 + onClick={handleEditPermissions} 43 + classList={{ 44 + "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!": true, 45 + }} 46 + > 47 + Edit permissions 48 + </Button> 49 + </div> 50 + </Modal> 51 + ); 52 + };
+229 -7
src/components/create/index.tsx
··· 235 235 onClose={() => setOpenDialog(false)} 236 236 closeOnClick={false} 237 237 nonBlocking={isMinimized()} 238 + alignTop 239 + contentClass={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700 ${isMaximized() ? "w-[calc(100%-1rem)] max-w-7xl h-[85vh]" : "w-[calc(100%-1rem)] max-w-3xl h-[65vh]"} ${isMinimized() ? "hidden" : ""}`} 238 240 > 239 - <div 240 - style="transform: translateX(-50%) translateZ(0);" 241 - classList={{ 242 - "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto absolute top-18 left-1/2 flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-all duration-200 dark:border-neutral-700 starting:opacity-0": true, 243 - "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(), 244 - "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(), 245 - hidden: isMinimized(), 241 + <div class="mb-2 flex w-full justify-between text-base"> 242 + <div class="flex items-center gap-2"> 243 + <span class="font-semibold select-none"> 244 + {props.create ? "Creating" : "Editing"} record 245 + </span> 246 + </div> 247 + <div class="flex items-center gap-1"> 248 + <button 249 + type="button" 250 + onclick={() => setIsMinimized(true)} 251 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 252 + > 253 + <span class="iconify lucide--minus"></span> 254 + </button> 255 + <button 256 + type="button" 257 + onclick={() => setIsMaximized(!isMaximized())} 258 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 259 + > 260 + <span 261 + class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 262 + ></span> 263 + </button> 264 + <button 265 + id="close" 266 + onclick={() => setOpenDialog(false)} 267 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 268 + > 269 + <span class="iconify lucide--x"></span> 270 + </button> 271 + </div> 272 + </div> 273 + <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 274 + <Show when={props.create}> 275 + <div class="flex flex-wrap items-center gap-1 text-sm"> 276 + <span>at://</span> 277 + <select 278 + class="dark:bg-dark-100 max-w-40 truncate rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 279 + name="repo" 280 + id="repo" 281 + > 282 + <For each={Object.keys(sessions)}> 283 + {(session) => ( 284 + <option value={session} selected={session === agent()?.sub}> 285 + {sessions[session].handle ?? session} 286 + </option> 287 + )} 288 + </For> 289 + </select> 290 + <span>/</span> 291 + <TextInput 292 + id="collection" 293 + name="collection" 294 + placeholder="Collection (default: $type)" 295 + class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 296 + onInput={(e) => { 297 + const value = e.currentTarget.value; 298 + if (!value || isNsid(value)) setCollectionError(""); 299 + else 300 + setCollectionError( 301 + "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", 302 + ); 303 + }} 304 + /> 305 + <span>/</span> 306 + <TextInput 307 + id="rkey" 308 + name="rkey" 309 + placeholder="Record key (default: TID)" 310 + class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 311 + onInput={(e) => { 312 + const value = e.currentTarget.value; 313 + if (!value || isRecordKey(value)) setRkeyError(""); 314 + else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); 315 + }} 316 + /> 317 + </div> 318 + <Show when={collectionError() || rkeyError()}> 319 + <div class="text-xs text-red-500 dark:text-red-400"> 320 + <div>{collectionError()}</div> 321 + <div>{rkeyError()}</div> 322 + </div> 323 + </Show> 324 + </Show> 325 + <div class="min-h-0 flex-1"> 326 + <Suspense 327 + fallback={ 328 + <div class="flex h-full items-center justify-center"> 329 + <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 330 + </div> 331 + } 332 + > 333 + <Editor 334 + content={JSON.stringify( 335 + !props.create ? props.record 336 + : params.rkey ? placeholder() 337 + : defaultPlaceholder(), 338 + null, 339 + 2, 340 + )} 341 + /> 342 + </Suspense> 343 + </div> 344 + <div class="flex flex-col gap-2"> 345 + <Show when={notice()}> 346 + <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 347 + </Show> 348 + <div class="flex justify-between gap-2"> 349 + <div class="relative" ref={insertMenuRef}> 350 + <Button onClick={() => setOpenInsertMenu(!openInsertMenu())}> 351 + <span class="iconify lucide--plus"></span> 352 + <span>Add</span> 353 + </Button> 354 + <Show when={openInsertMenu()}> 355 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 356 + <MenuItem 357 + icon="lucide--id-card" 358 + label="Insert DID" 359 + onClick={insertDidFromHandle} 360 + /> 361 + <MenuItem 362 + icon="lucide--clock" 363 + label="Insert timestamp" 364 + onClick={insertTimestamp} 365 + /> 366 + <button 367 + type="button" 368 + class={ 369 + hasUserScope("blob") ? 370 + "flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 371 + : "flex items-center gap-2 rounded-md p-2 text-left text-xs opacity-40" 372 + } 373 + onClick={() => { 374 + if (hasUserScope("blob")) { 375 + setOpenInsertMenu(false); 376 + blobInput.click(); 377 + } 378 + }} 379 + > 380 + <span class="iconify lucide--upload shrink-0"></span> 381 + <span>Upload blob{hasUserScope("blob") ? "" : " (permission needed)"}</span> 382 + </button> 383 + </div> 384 + </Show> 385 + <input 386 + type="file" 387 + id="blob" 388 + class="sr-only" 389 + ref={blobInput} 390 + onChange={(e) => { 391 + if (e.target.files !== null) setOpenUpload(true); 392 + }} 393 + /> 394 + </div> 395 + <Modal 396 + open={openUpload()} 397 + onClose={() => setOpenUpload(false)} 398 + closeOnClick={false} 399 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 400 + > 401 + <FileUpload 402 + file={blobInput.files![0]} 403 + blobInput={blobInput} 404 + onClose={() => setOpenUpload(false)} 405 + /> 406 + </Modal> 407 + <Modal 408 + open={openHandleDialog()} 409 + onClose={() => setOpenHandleDialog(false)} 410 + closeOnClick={false} 411 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 412 + > 413 + <HandleInput onClose={() => setOpenHandleDialog(false)} /> 414 + </Modal> 415 + <Modal 416 + open={openConfirmDialog()} 417 + onClose={() => setOpenConfirmDialog(false)} 418 + closeOnClick={false} 419 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[24rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 420 + > 421 + <ConfirmSubmit 422 + isCreate={props.create} 423 + onConfirm={(validate, recreate) => { 424 + if (props.create) { 425 + createRecord(validate); 426 + } else { 427 + editRecord(validate, recreate); 428 + } 429 + }} 430 + onClose={() => setOpenConfirmDialog(false)} 431 + /> 432 + </Modal> 433 + <div class="flex items-center justify-end gap-2"> 434 + <Button onClick={() => setOpenConfirmDialog(true)}> 435 + {props.create ? "Create..." : "Edit..."} 436 + </Button> 437 + </div> 438 + </div> 439 + </div> 440 + </form> 441 + </Modal> 442 + <Show when={isMinimized() && openDialog()}> 443 + <button 444 + 445 + 446 + 447 + 448 + 449 + 450 + 451 + 452 + 453 + 454 + 455 + 456 + 457 + 458 + 459 + 460 + <button 461 + class={ 462 + hasPermission() ? 463 + `flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-md" : "rounded-sm"}` 464 + : `flex items-center p-1.5 opacity-40 ${props.create ? "rounded-md" : "rounded-sm"}` 465 + } 466 + onclick={() => { 467 + if (hasPermission()) {
+46 -1
src/components/modal.tsx
··· 5 5 onClose?: () => void; 6 6 closeOnClick?: boolean; 7 7 nonBlocking?: boolean; 8 + alignTop?: boolean; 9 + contentClass?: string; 8 10 } 9 11 10 12 export const Modal = (props: ModalProps) => { ··· 12 14 <Show when={props.open}> 13 15 <div 14 16 data-modal 15 - class="fixed inset-0 z-50 h-full max-h-none w-full max-w-none bg-transparent text-neutral-900 dark:text-neutral-200" 17 + class="fixed inset-0 z-50 flex h-full max-h-none w-full max-w-none justify-center bg-transparent text-neutral-900 dark:text-neutral-200" 16 18 classList={{ 17 19 "pointer-events-none": props.nonBlocking, 20 + "items-start pt-18": props.alignTop, 21 + "items-center": !props.alignTop, 18 22 }} 19 23 ref={(node) => { 20 24 const handleEscape = (e: KeyboardEvent) => { 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + } 56 + }} 57 + > 58 + <div 59 + class={`transition-all starting:scale-95 starting:opacity-0 ${props.contentClass ?? ""}`} 60 + > 61 + {props.children} 62 + </div> 63 + </div> 64 + </Show> 65 + );
+20 -6
src/views/record.tsx
··· 415 415 416 416 417 417 418 - 418 + > 419 419 <span class="iconify lucide--trash-2"></span> 420 420 </PermissionButton> 421 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 422 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 423 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 424 - <div class="flex justify-end gap-2"> 425 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 421 + <Modal 422 + open={openDelete()} 423 + onClose={() => setOpenDelete(false)} 424 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 425 + > 426 + <h2 class="mb-2 font-semibold">Delete this record?</h2> 427 + <div class="flex justify-end gap-2"> 428 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 429 + <Button 430 + onClick={deleteRecord} 431 + classList={{ 432 + "bg-red-500! border-none! text-white! hover:bg-red-400! active:bg-red-400!": true, 433 + }} 434 + > 435 + Delete 436 + </Button> 437 + </div> 438 + </Modal> 439 + </Show>
+365 -315
src/views/stream/index.tsx
··· 3 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 5 import { Button } from "../../components/button"; 6 + import DidHoverCard from "../../components/hover-card/did"; 6 7 import { JSONValue } from "../../components/json"; 7 - import { StickyOverlay } from "../../components/sticky"; 8 8 import { TextInput } from "../../components/text-input"; 9 + import { addToClipboard } from "../../utils/copy"; 10 + import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 9 11 import { StreamStats, StreamStatsPanel } from "./stats"; 10 12 11 13 const LIMIT = 20; 12 - type Parameter = { name: string; param: string | string[] | undefined }; 13 14 14 - const StreamView = () => { 15 + const TYPE_COLORS: Record<string, string> = { 16 + create: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", 17 + update: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", 18 + delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", 19 + identity: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", 20 + account: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", 21 + sync: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", 22 + }; 23 + 24 + const StreamRecordItem = (props: { record: any; streamType: StreamType }) => { 25 + const [expanded, setExpanded] = createSignal(false); 26 + const config = () => STREAM_CONFIGS[props.streamType]; 27 + const info = () => config().parseRecord(props.record); 28 + 29 + const displayType = () => { 30 + const i = info(); 31 + return i.type === "commit" || i.type === "link" ? i.action : i.type; 32 + }; 33 + 34 + const copyRecord = (e: MouseEvent) => { 35 + e.stopPropagation(); 36 + addToClipboard(JSON.stringify(props.record, null, 2)); 37 + }; 38 + 39 + return ( 40 + <div class="flex flex-col gap-2"> 41 + <div class="flex items-start gap-1"> 42 + <button 43 + type="button" 44 + onclick={() => setExpanded(!expanded())} 45 + class="dark:hover:bg-dark-200 flex min-w-0 flex-1 items-start gap-2 rounded p-1 text-left hover:bg-neutral-200/70" 46 + > 47 + <span class="mt-0.5 shrink-0 text-neutral-400 dark:text-neutral-500"> 48 + {expanded() ? 49 + <span class="iconify lucide--chevron-down"></span> 50 + : <span class="iconify lucide--chevron-right"></span>} 51 + </span> 52 + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> 53 + <div class="flex items-center gap-x-1.5 sm:gap-x-2"> 54 + <span 55 + class={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${TYPE_COLORS[displayType()!] || "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300"}`} 56 + > 57 + {displayType()} 58 + </span> 59 + <Show when={info().collection && info().collection !== info().type}> 60 + <span class="min-w-0 truncate text-neutral-600 dark:text-neutral-300"> 61 + {info().collection} 62 + </span> 63 + </Show> 64 + <Show when={info().rkey}> 65 + <span class="max-w-full shrink-0 truncate text-neutral-400 dark:text-neutral-500"> 66 + {info().rkey} 67 + </span> 68 + </Show> 69 + </div> 70 + <div class="flex flex-col gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:flex-row sm:items-center dark:text-neutral-400"> 71 + <Show when={info().did}> 72 + <span class="w-fit" onclick={(e) => e.stopPropagation()}> 73 + <DidHoverCard newTab did={info().did!} /> 74 + </span> 75 + </Show> 76 + <Show when={info().time}> 77 + <span>{info().time}</span> 78 + </Show> 79 + </div> 80 + </div> 81 + </button> 82 + <Show when={expanded()}> 83 + <button 84 + type="button" 85 + onclick={copyRecord} 86 + class="flex size-6 shrink-0 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-600 active:bg-neutral-300 sm:size-7 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:active:bg-neutral-600" 87 + > 88 + <span class="iconify lucide--copy"></span> 89 + </button> 90 + </Show> 91 + </div> 92 + <Show when={expanded()}> 93 + <div class="ml-6.5"> 94 + <div class="w-full text-xs wrap-anywhere whitespace-pre-wrap md:w-2xl"> 95 + <JSONValue newTab data={props.record} repo={info().did ?? ""} hideBlobs /> 96 + </div> 97 + </div> 98 + </Show> 99 + </div> 100 + ); 101 + }; 102 + 103 + export const StreamView = () => { 15 104 const [searchParams, setSearchParams] = useSearchParams(); 16 - const [parameters, setParameters] = createSignal<Parameter[]>([]); 17 - const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 18 - const [records, setRecords] = createSignal<Array<any>>([]); 105 + const streamType = getStreamType(useLocation().pathname); 106 + const config = () => STREAM_CONFIGS[streamType]; 107 + 108 + const [records, setRecords] = createSignal<any[]>([]); 19 109 const [connected, setConnected] = createSignal(false); 20 110 const [paused, setPaused] = createSignal(false); 21 111 const [notice, setNotice] = createSignal(""); 112 + const [parameters, setParameters] = createSignal<{ name: string; value?: string }[]>([]); 113 + const [stats, setStats] = createSignal<StreamStats>({ 114 + totalEvents: 0, 115 + eventsPerSecond: 0, 22 116 117 + collections: {}, 118 + }); 119 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 23 120 121 + let socket: WebSocket; 122 + let firehose: Firehose; 123 + let formRef!: HTMLFormElement; 24 124 125 + let rafId: number | null = null; 126 + let statsIntervalId: number | null = null; 127 + let statsUpdateIntervalId: number | null = null; 128 + let currentSecondEventCount = 0; 129 + let totalEventsCount = 0; 130 + let eventTypesMap: Record<string, number> = {}; 131 + let collectionsMap: Record<string, number> = {}; 25 132 133 + const addRecord = (record: any) => { 134 + currentSecondEventCount++; 135 + totalEventsCount++; 26 136 137 + const rawEventType = record.kind || record.$type || "unknown"; 138 + const eventType = rawEventType.includes("#") ? rawEventType.split("#").pop() : rawEventType; 139 + eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 27 140 141 + if (eventType !== "account" && eventType !== "identity") { 142 + const collection = 143 + record.commit?.collection || 144 + record.op?.path?.split("/")[0] || 145 + record.link?.source || 146 + "unknown"; 147 + collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 148 + } 28 149 29 150 30 151 ··· 36 157 37 158 38 159 160 + }; 39 161 162 + const disconnect = () => { 163 + if (!config().useFirehoseLib) socket?.close(); 164 + else firehose?.close(); 40 165 166 + if (rafId !== null) { 167 + cancelAnimationFrame(rafId); 168 + rafId = null; 41 169 42 170 43 171 44 172 45 173 46 174 175 + clearInterval(statsUpdateIntervalId); 176 + statsUpdateIntervalId = null; 177 + } 47 178 179 + pendingRecords = []; 180 + totalEventsCount = 0; 181 + eventTypesMap = {}; 182 + collectionsMap = {}; 183 + setConnected(false); 184 + setPaused(false); 185 + setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 186 + }; 48 187 188 + const connectStream = async (formData: FormData) => { 189 + setNotice(""); 190 + if (connected()) { 191 + disconnect(); 49 192 193 + } 194 + setRecords([]); 50 195 196 + const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 197 + const url = config().buildUrl(instance, formData); 51 198 199 + // Save all form fields to URL params 200 + const params: Record<string, string | undefined> = { instance }; 201 + config().fields.forEach((field) => { 202 + params[field.searchParam] = formData.get(field.name)?.toString(); 203 + }); 204 + setSearchParams(params); 52 205 206 + // Build parameters display 207 + setParameters([ 208 + { name: "Instance", value: instance }, 209 + ...config() 210 + .fields.filter((f) => f.type !== "checkbox") 211 + .map((f) => ({ name: f.label, value: formData.get(f.name)?.toString() })), 212 + ...config() 213 + .fields.filter((f) => f.type === "checkbox" && formData.get(f.name) === "on") 214 + .map((f) => ({ name: f.label, value: "on" })), 215 + ]); 53 216 217 + setConnected(true); 218 + const now = Date.now(); 219 + setCurrentTime(now); 54 220 221 + totalEventsCount = 0; 222 + eventTypesMap = {}; 223 + collectionsMap = {}; 55 224 56 225 57 226 ··· 67 236 68 237 69 238 239 + })); 240 + }, 50); 70 241 242 + statsIntervalId = window.setInterval(() => { 243 + setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 244 + currentSecondEventCount = 0; 245 + setCurrentTime(Date.now()); 246 + }, 1000); 71 247 248 + if (!config().useFirehoseLib) { 249 + socket = new WebSocket(url); 250 + socket.addEventListener("message", (event) => { 251 + const rec = JSON.parse(event.data); 252 + const isFilteredEvent = rec.kind === "account" || rec.kind === "identity"; 253 + if (!isFilteredEvent || streamType !== "jetstream" || searchParams.allEvents === "on") 254 + addRecord(rec); 255 + }); 256 + socket.addEventListener("error", () => { 72 257 258 + disconnect(); 259 + }); 260 + } else { 261 + const cursor = formData.get("cursor")?.toString(); 262 + firehose = new Firehose({ 263 + relay: url, 264 + cursor: cursor, 73 265 74 266 75 267 ··· 77 269 78 270 79 271 272 + }); 273 + firehose.on("commit", (commit) => { 274 + for (const op of commit.ops) { 275 + addRecord({ 276 + $type: commit.$type, 277 + repo: commit.repo, 278 + seq: commit.seq, 80 279 280 + rev: commit.rev, 281 + since: commit.since, 282 + op: op, 283 + }); 284 + } 285 + }); 286 + firehose.on("identity", (identity) => addRecord(identity)); 287 + firehose.on("account", (account) => addRecord(account)); 288 + firehose.on("sync", (sync) => { 289 + addRecord({ 290 + $type: sync.$type, 291 + did: sync.did, 292 + rev: sync.rev, 293 + seq: sync.seq, 294 + time: sync.time, 295 + }); 296 + }); 297 + firehose.start(); 298 + } 299 + }; 81 300 301 + onMount(() => { 302 + if (searchParams.instance) { 303 + const formData = new FormData(); 304 + formData.append("instance", searchParams.instance.toString()); 305 + config().fields.forEach((field) => { 306 + const value = searchParams[field.searchParam]; 307 + if (value) formData.append(field.name, value.toString()); 308 + }); 309 + connectStream(formData); 310 + } 311 + }); 82 312 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 - 126 - 127 - 128 - 129 - 130 - 131 - 132 - 133 - 134 - 135 - 136 - 137 - 138 - 139 - 140 - 141 - 142 - 143 - 144 - 145 - 146 - 147 - 148 - 149 - 150 - 151 - 152 - 153 - 154 - 155 - 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 - 164 - 165 - 166 - 167 - 168 - 169 - 170 - 171 - 172 - 173 - 174 - 175 - 176 - 177 - 178 - 179 - 180 - 181 - 182 - 183 - 184 - 185 - 186 - 187 - 188 - 189 - 190 - 191 - 192 - 193 - 194 - 195 - 196 - 197 - 198 - 199 - 200 - 201 - 202 - 203 - 204 - 205 - 206 - 207 - 208 - 209 - 210 - 211 - 212 - 213 - 214 - 215 - 216 - 217 - 218 - 219 - 220 - 221 - 222 - 223 - 224 - 225 - 226 - 227 - 228 - 229 - 230 - 231 - 232 - 233 - 234 - 235 - 236 - 237 - 238 - 239 - 240 - 241 - 242 - 243 - 244 - 245 - 246 - 247 - 248 - 249 - 250 - 251 - 252 - 253 - 254 - 255 - 256 - 257 - 258 - 259 - 260 - 261 - 262 - 263 - 264 - 313 + onCleanup(() => { 314 + socket?.close(); 315 + firehose?.close(); 316 + if (rafId !== null) cancelAnimationFrame(rafId); 317 + if (statsIntervalId !== null) clearInterval(statsIntervalId); 318 + if (statsUpdateIntervalId !== null) clearInterval(statsUpdateIntervalId); 319 + }); 265 320 266 321 return ( 267 322 <> 268 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 269 - <div class="flex w-full flex-col items-center"> 323 + <Title>{config().label} - PDSls</Title> 324 + <div class="flex w-full flex-col items-center gap-2"> 325 + {/* Tab Navigation */} 270 326 <div class="flex gap-4 font-medium"> 271 - <A 272 - class="flex items-center gap-1 border-b-2" 273 - 274 - 275 - 276 - 277 - 278 - 279 - 280 - 281 - 282 - 327 + <For each={STREAM_TYPES}> 328 + {(type) => ( 329 + <A 330 + class="flex items-center gap-1 border-b-2" 331 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 332 + href={`/${type}`} 333 + > 334 + {STREAM_CONFIGS[type].label} 335 + </A> 336 + )} 337 + </For> 338 + </div> 283 339 284 - </A> 340 + {/* Stream Description */} 341 + <div class="w-full px-2 text-center"> 342 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{config().description}</p> 285 343 </div> 344 + 345 + {/* Connection Form */} 286 346 <Show when={!connected()}> 287 - <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 347 + <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 288 348 <label class="flex items-center justify-end gap-x-1"> 289 - <span class="min-w-20">Instance</span> 349 + <span class="min-w-21 select-none">Instance</span> 290 350 <TextInput 351 + name="instance" 352 + value={searchParams.instance ?? config().defaultInstance} 353 + class="grow" 354 + /> 355 + </label> 356 + 357 + <For each={config().fields}> 358 + {(field) => ( 359 + <label class="flex items-center justify-end gap-x-1"> 360 + <Show when={field.type === "checkbox"}> 361 + <input 362 + type="checkbox" 363 + name={field.name} 364 + id={field.name} 365 + checked={searchParams[field.searchParam] === "on"} 366 + /> 367 + </Show> 368 + <span class="min-w-21 select-none">{field.label}</span> 369 + <Show when={field.type === "textarea"}> 370 + <textarea 371 + name={field.name} 372 + spellcheck={false} 373 + placeholder={field.placeholder} 374 + value={(searchParams[field.searchParam] as string) ?? ""} 375 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 376 + /> 377 + </Show> 378 + <Show when={field.type === "text"}> 379 + <TextInput 380 + name={field.name} 381 + placeholder={field.placeholder} 382 + value={(searchParams[field.searchParam] as string) ?? ""} 383 + class="grow" 384 + /> 385 + </Show> 386 + </label> 387 + )} 388 + </For> 291 389 292 - 293 - 294 - 295 - 296 - 297 - 298 - 299 - 300 - 301 - 302 - 303 - 304 - 305 - 306 - 307 - 308 - 309 - 310 - 311 - 312 - 313 - 314 - 315 - 316 - 317 - 318 - 319 - 320 - 321 - 322 - 323 - 324 - 325 - 326 - 327 - 328 - 329 - 330 - 331 - 332 - 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - 345 - 346 - 347 - 348 - 349 - 390 + <div class="flex justify-end gap-2"> 391 + <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 392 + </div> 350 393 </form> 351 394 </Show> 395 + 396 + {/* Connected State */} 352 397 <Show when={connected()}> 353 - <StickyOverlay> 354 - <div class="flex w-full flex-col gap-2 p-1"> 355 - <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 356 - <div class="font-semibold">Parameters</div> 357 - <For each={parameters()}> 358 - {(param) => ( 359 - <Show when={param.param}> 360 - <div class="text-sm"> 361 - <div class="text-xs text-neutral-500 dark:text-neutral-400"> 362 - {param.name} 363 - </div> 364 - <div class="text-neutral-700 dark:text-neutral-300">{param.param}</div> 365 - </div> 366 - </Show> 367 - )} 368 - </For> 369 - </div> 370 - <StreamStatsPanel stats={stats()} currentTime={currentTime()} /> 371 - <div class="flex justify-end gap-2"> 372 - <button 373 - type="button" 374 - ontouchstart={(e) => { 375 - e.preventDefault(); 376 - requestAnimationFrame(() => togglePause()); 377 - }} 378 - onclick={togglePause} 379 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 380 - > 381 - {paused() ? "Resume" : "Pause"} 382 - </button> 383 - <button 384 - type="button" 385 - ontouchstart={(e) => { 386 - e.preventDefault(); 387 - requestAnimationFrame(() => disconnect()); 388 - }} 389 - onclick={disconnect} 390 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 391 - > 392 - Disconnect 393 - </button> 394 - </div> 398 + <div class="flex w-full flex-col gap-2 p-2"> 399 + <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 400 + <div class="font-semibold">Parameters</div> 401 + <For each={parameters()}> 402 + {(param) => ( 403 + <Show when={param.value}> 404 + <div class="text-sm"> 405 + <div class="text-xs text-neutral-500 dark:text-neutral-400">{param.name}</div> 406 + <div class="text-neutral-700 dark:text-neutral-300">{param.value}</div> 407 + </div> 408 + </Show> 409 + )} 410 + </For> 411 + </div> 412 + <StreamStatsPanel 413 + stats={stats()} 414 + currentTime={currentTime()} 415 + streamType={streamType} 416 + showAllEvents={searchParams.allEvents === "on"} 417 + /> 418 + <div class="flex justify-end gap-2"> 419 + <Button 420 + ontouchstart={(e) => { 421 + e.preventDefault(); 422 + requestAnimationFrame(() => setPaused(!paused())); 423 + }} 424 + onClick={() => setPaused(!paused())} 425 + > 426 + {paused() ? "Resume" : "Pause"} 427 + </Button> 428 + <Button 429 + ontouchstart={(e) => { 430 + e.preventDefault(); 431 + requestAnimationFrame(() => disconnect()); 432 + }} 433 + onClick={disconnect} 434 + > 435 + Disconnect 436 + </Button> 395 437 </div> 396 - </StickyOverlay> 438 + </div> 397 439 </Show> 440 + 441 + {/* Error Notice */} 398 442 <Show when={notice().length}> 399 443 <div class="text-red-500 dark:text-red-400">{notice()}</div> 400 444 </Show> 401 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:text-sm md:w-3xl"> 402 - <For each={records().toReversed()}> 403 - {(rec) => ( 404 - <div class="pb-2"> 405 - <JSONValue data={rec} repo={rec.did ?? rec.repo} hideBlobs /> 406 - </div> 407 - )} 408 - </For> 409 - </div> 445 + 446 + {/* Records List */} 447 + <Show when={connected() || records().length > 0}> 448 + <div class="flex min-h-280 w-full flex-col gap-2 font-mono text-xs [overflow-anchor:auto] sm:text-sm"> 449 + <For each={records().toReversed()}> 450 + {(rec) => ( 451 + <div class="[overflow-anchor:none]"> 452 + <StreamRecordItem record={rec} streamType={streamType} /> 453 + </div> 454 + )} 455 + </For> 456 + <div class="h-px [overflow-anchor:auto]" /> 457 + </div> 458 + </Show> 410 459 </div> 411 460 </> 412 461 ); 462 + };
+221
src/views/stream/config.ts
··· 1 + import { localDateFromTimestamp } from "../../utils/date"; 2 + 3 + export type StreamType = "jetstream" | "firehose" | "spacedust"; 4 + 5 + export type FormField = { 6 + name: string; 7 + label: string; 8 + type: "text" | "textarea" | "checkbox"; 9 + placeholder?: string; 10 + searchParam: string; 11 + }; 12 + 13 + export type RecordInfo = { 14 + type: string; 15 + did?: string; 16 + collection?: string; 17 + rkey?: string; 18 + action?: string; 19 + time?: string; 20 + }; 21 + 22 + export type StreamConfig = { 23 + label: string; 24 + description: string; 25 + icon: string; 26 + defaultInstance: string; 27 + fields: FormField[]; 28 + useFirehoseLib: boolean; 29 + buildUrl: (instance: string, formData: FormData) => string; 30 + parseRecord: (record: any) => RecordInfo; 31 + showEventTypes: boolean; 32 + collectionsLabel: string; 33 + }; 34 + 35 + export const STREAM_CONFIGS: Record<StreamType, StreamConfig> = { 36 + jetstream: { 37 + label: "Jetstream", 38 + description: "A simplified event stream with support for collection and DID filtering.", 39 + icon: "lucide--radio-tower", 40 + defaultInstance: "wss://jetstream1.us-east.bsky.network/subscribe", 41 + useFirehoseLib: false, 42 + showEventTypes: true, 43 + collectionsLabel: "Top Collections", 44 + fields: [ 45 + { 46 + name: "collections", 47 + label: "Collections", 48 + type: "textarea", 49 + placeholder: "Comma-separated list of collections", 50 + searchParam: "collections", 51 + }, 52 + { 53 + name: "dids", 54 + label: "DIDs", 55 + type: "textarea", 56 + placeholder: "Comma-separated list of DIDs", 57 + searchParam: "dids", 58 + }, 59 + { 60 + name: "cursor", 61 + label: "Cursor", 62 + type: "text", 63 + placeholder: "Leave empty for live-tail", 64 + searchParam: "cursor", 65 + }, 66 + { 67 + name: "allEvents", 68 + label: "Show account and identity events", 69 + type: "checkbox", 70 + searchParam: "allEvents", 71 + }, 72 + ], 73 + buildUrl: (instance, formData) => { 74 + let url = instance + "?"; 75 + 76 + const collections = formData.get("collections")?.toString().split(","); 77 + collections?.forEach((c) => { 78 + if (c.trim().length) url += `wantedCollections=${c.trim()}&`; 79 + }); 80 + 81 + const dids = formData.get("dids")?.toString().split(","); 82 + dids?.forEach((d) => { 83 + if (d.trim().length) url += `wantedDids=${d.trim()}&`; 84 + }); 85 + 86 + const cursor = formData.get("cursor")?.toString(); 87 + if (cursor?.length) url += `cursor=${cursor}&`; 88 + 89 + return url.replace(/[&?]$/, ""); 90 + }, 91 + parseRecord: (rec) => { 92 + const collection = rec.commit?.collection || rec.kind; 93 + const rkey = rec.commit?.rkey; 94 + const action = rec.commit?.operation; 95 + const time = rec.time_us ? localDateFromTimestamp(rec.time_us / 1000) : undefined; 96 + return { type: rec.kind, did: rec.did, collection, rkey, action, time }; 97 + }, 98 + }, 99 + 100 + firehose: { 101 + label: "Firehose", 102 + description: "The raw event stream from a relay or PDS.", 103 + icon: "lucide--rss", 104 + defaultInstance: "wss://bsky.network", 105 + useFirehoseLib: true, 106 + showEventTypes: true, 107 + collectionsLabel: "Top Collections", 108 + fields: [ 109 + { 110 + name: "cursor", 111 + label: "Cursor", 112 + type: "text", 113 + placeholder: "Leave empty for live-tail", 114 + searchParam: "cursor", 115 + }, 116 + ], 117 + buildUrl: (instance, _formData) => { 118 + let url = instance; 119 + url = url.replace("/xrpc/com.atproto.sync.subscribeRepos", ""); 120 + if (!(url.startsWith("wss://") || url.startsWith("ws://"))) { 121 + url = "wss://" + url; 122 + } 123 + return url; 124 + }, 125 + parseRecord: (rec) => { 126 + const type = rec.$type?.split("#").pop() || rec.$type; 127 + const did = rec.repo ?? rec.did; 128 + const pathParts = rec.op?.path?.split("/") || []; 129 + const collection = pathParts[0]; 130 + const rkey = pathParts[1]; 131 + const time = rec.time ? localDateFromTimestamp(Date.parse(rec.time)) : undefined; 132 + return { type, did, collection, rkey, action: rec.op?.action, time }; 133 + }, 134 + }, 135 + 136 + spacedust: { 137 + label: "Spacedust", 138 + description: "A stream of links showing interactions across the network.", 139 + icon: "lucide--link", 140 + defaultInstance: "wss://spacedust.microcosm.blue/subscribe", 141 + useFirehoseLib: false, 142 + showEventTypes: false, 143 + collectionsLabel: "Top Sources", 144 + fields: [ 145 + { 146 + name: "sources", 147 + label: "Sources", 148 + type: "textarea", 149 + placeholder: "e.g. app.bsky.graph.follow:subject", 150 + searchParam: "sources", 151 + }, 152 + { 153 + name: "subjectDids", 154 + label: "Subject DIDs", 155 + type: "textarea", 156 + placeholder: "Comma-separated list of DIDs", 157 + searchParam: "subjectDids", 158 + }, 159 + { 160 + name: "subjects", 161 + label: "Subjects", 162 + type: "textarea", 163 + placeholder: "Comma-separated list of AT URIs", 164 + searchParam: "subjects", 165 + }, 166 + { 167 + name: "instant", 168 + label: "Instant mode (bypass 21s delay buffer)", 169 + type: "checkbox", 170 + searchParam: "instant", 171 + }, 172 + ], 173 + buildUrl: (instance, formData) => { 174 + let url = instance + "?"; 175 + 176 + const sources = formData.get("sources")?.toString().split(","); 177 + sources?.forEach((s) => { 178 + if (s.trim().length) url += `wantedSources=${s.trim()}&`; 179 + }); 180 + 181 + const subjectDids = formData.get("subjectDids")?.toString().split(","); 182 + subjectDids?.forEach((d) => { 183 + if (d.trim().length) url += `wantedSubjectDids=${d.trim()}&`; 184 + }); 185 + 186 + const subjects = formData.get("subjects")?.toString().split(","); 187 + subjects?.forEach((s) => { 188 + if (s.trim().length) url += `wantedSubjects=${encodeURIComponent(s.trim())}&`; 189 + }); 190 + 191 + const instant = formData.get("instant")?.toString(); 192 + if (instant === "on") url += `instant=true&`; 193 + 194 + return url.replace(/[&?]$/, ""); 195 + }, 196 + parseRecord: (rec) => { 197 + const source = rec.link?.source; 198 + const sourceRecord = rec.link?.source_record; 199 + const uriParts = sourceRecord?.replace("at://", "").split("/") || []; 200 + const did = uriParts[0]; 201 + const collection = uriParts[1]; 202 + const rkey = uriParts[2]; 203 + return { 204 + type: rec.kind, 205 + did, 206 + collection: source || collection, 207 + rkey, 208 + action: rec.link?.operation, 209 + time: undefined, 210 + }; 211 + }, 212 + }, 213 + }; 214 + 215 + export const STREAM_TYPES = Object.keys(STREAM_CONFIGS) as StreamType[]; 216 + 217 + export const getStreamType = (pathname: string): StreamType => { 218 + if (pathname === "/firehose") return "firehose"; 219 + if (pathname === "/spacedust") return "spacedust"; 220 + return "jetstream"; 221 + };
+14 -14
src/layout.tsx
··· 9 9 import { NavBar } from "./components/navbar.jsx"; 10 10 import { NotificationContainer } from "./components/notification.jsx"; 11 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 - import { Search, SearchButton, showSearch } from "./components/search.jsx"; 12 + import { Search, SearchButton } from "./components/search.jsx"; 13 13 import { themeEvent } from "./components/theme.jsx"; 14 14 import { resolveHandle } from "./utils/api.js"; 15 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 126 </Show> 127 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 128 128 <header 129 - class={`dark:shadow-dark-700 mb-3 flex w-full items-center justify-between rounded-xl border-[0.5px] border-neutral-300 bg-neutral-50 bg-size-[95%] bg-right bg-no-repeat p-2 pl-3 shadow-xs [--header-bg:#fafafa] [--trans-blue:#5BCEFA90] [--trans-pink:#F5A9B890] [--trans-white:#FFFFFF90] dark:border-neutral-700 dark:bg-neutral-800 dark:[--header-bg:#262626] dark:[--trans-blue:#5BCEFAa0] dark:[--trans-pink:#F5A9B8a0] dark:[--trans-white:#FFFFFFa0] ${localStorage.getItem("hrt") === "true" ? "bg-[linear-gradient(to_left,transparent_10%,var(--header-bg)_85%),linear-gradient(to_bottom,var(--trans-blue)_0%,var(--trans-blue)_20%,var(--trans-pink)_20%,var(--trans-pink)_40%,var(--trans-white)_40%,var(--trans-white)_60%,var(--trans-pink)_60%,var(--trans-pink)_80%,var(--trans-blue)_80%,var(--trans-blue)_100%)]" : ""}`} 129 + class={`dark:shadow-dark-700 mb-3 flex h-13 w-full items-center justify-between rounded-xl border-[0.5px] border-neutral-300 bg-neutral-50 bg-size-[95%] bg-right bg-no-repeat p-2 pl-3 shadow-xs [--header-bg:#fafafa] [--trans-blue:#5BCEFA90] [--trans-pink:#F5A9B890] [--trans-white:#FFFFFF90] dark:border-neutral-700 dark:bg-neutral-800 dark:[--header-bg:#262626] dark:[--trans-blue:#5BCEFAa0] dark:[--trans-pink:#F5A9B8a0] dark:[--trans-white:#FFFFFFa0] ${localStorage.getItem("hrt") === "true" ? "bg-[linear-gradient(to_left,transparent_10%,var(--header-bg)_85%),linear-gradient(to_bottom,var(--trans-blue)_0%,var(--trans-blue)_20%,var(--trans-pink)_20%,var(--trans-pink)_40%,var(--trans-white)_40%,var(--trans-white)_60%,var(--trans-pink)_60%,var(--trans-pink)_80%,var(--trans-blue)_80%,var(--trans-blue)_100%)]" : ""}`} 130 130 style={{ 131 131 "background-image": 132 132 props.params.repo && props.params.repo in headers ? ··· 149 149 /> 150 150 </Show> 151 151 </A> 152 - <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 px-1 py-0.5 dark:bg-neutral-800/60"> 153 - <SearchButton /> 152 + <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 p-1 dark:bg-neutral-800/60"> 153 + <div class="mr-1"> 154 + <SearchButton /> 155 + </div> 154 156 <Show when={agent()}> 155 157 <RecordEditor create={true} scope="create" /> 156 - 157 - 158 - 159 - 160 - 161 - 162 - 158 + </Show> 159 + <AccountManager /> 160 + <MenuProvider> 161 + <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-md p-1.5"> 162 + <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 163 + <NavMenu href="/firehose" label="Firehose" icon="lucide--rss" /> 164 + <NavMenu href="/spacedust" label="Spacedust" icon="lucide--orbit" /> 163 165 164 166 165 167 ··· 170 172 </div> 171 173 </header> 172 174 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 173 - <Show when={showSearch() || location.pathname === "/"}> 174 - <Search /> 175 - </Show> 175 + <Search /> 176 176 <Show when={props.params.pds}> 177 177 <NavBar params={props.params} /> 178 178 </Show>
+396 -319
pnpm-lock.yaml
··· 35 35 36 36 37 37 38 + '@atcute/identity-resolver': 39 + specifier: ^1.2.2 40 + version: 1.2.2(@atcute/identity@1.1.3) 41 + '@atcute/lexicon-doc': 42 + specifier: ^2.0.6 43 + version: 2.0.6 38 44 39 45 40 46 ··· 85 91 86 92 87 93 94 + version: 0.5.2 95 + '@solidjs/meta': 96 + specifier: ^0.29.4 97 + version: 0.29.4(solid-js@1.9.11) 98 + '@solidjs/router': 99 + specifier: ^0.15.4 100 + version: 0.15.4(solid-js@1.9.11) 101 + codemirror: 102 + specifier: ^6.0.2 103 + version: 6.0.2 88 104 105 + specifier: ^3.0.1 106 + version: 3.0.1 107 + solid-js: 108 + specifier: ^1.9.11 109 + version: 1.9.11 110 + devDependencies: 111 + '@iconify-json/lucide': 112 + specifier: ^1.2.86 89 113 90 114 91 115 116 + version: 1.2.1(tailwindcss@4.1.18) 117 + '@tailwindcss/vite': 118 + specifier: ^4.1.18 119 + version: 4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 120 + prettier: 121 + specifier: ^3.8.1 122 + version: 3.8.1 123 + prettier-plugin-organize-imports: 124 + specifier: ^4.3.0 125 + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) 126 + prettier-plugin-tailwindcss: 127 + specifier: ^0.7.2 128 + version: 0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1) 129 + tailwindcss: 130 + specifier: ^4.1.18 131 + version: 4.1.18 92 132 93 133 134 + version: 5.9.3 135 + vite: 136 + specifier: ^7.3.1 137 + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 138 + vite-plugin-solid: 139 + specifier: ^2.11.10 140 + version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 94 141 142 + packages: 95 143 96 144 97 145 ··· 128 176 129 177 130 178 179 + '@atcute/identity@1.1.3': 180 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 131 181 182 + '@atcute/lexicon-doc@2.0.6': 183 + resolution: {integrity: sha512-iDYJkuom+tIw3zIvU1ggCEVFfReXKfOUtIhpY2kEg2kQeSfMB75F+8k1QOpeAQBetyWYmjsHqBuSUX9oQS6L1Q==} 132 184 133 185 134 186 ··· 666 718 667 719 668 720 721 + '@noble/secp256k1@3.0.0': 722 + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 669 723 724 + '@rollup/rollup-android-arm-eabi@4.56.0': 725 + resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} 726 + cpu: [arm] 727 + os: [android] 670 728 729 + '@rollup/rollup-android-arm64@4.56.0': 730 + resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} 731 + cpu: [arm64] 732 + os: [android] 671 733 734 + '@rollup/rollup-darwin-arm64@4.56.0': 735 + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} 736 + cpu: [arm64] 737 + os: [darwin] 672 738 739 + '@rollup/rollup-darwin-x64@4.56.0': 740 + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} 741 + cpu: [x64] 742 + os: [darwin] 673 743 744 + '@rollup/rollup-freebsd-arm64@4.56.0': 745 + resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} 746 + cpu: [arm64] 747 + os: [freebsd] 674 748 749 + '@rollup/rollup-freebsd-x64@4.56.0': 750 + resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==} 751 + cpu: [x64] 752 + os: [freebsd] 675 753 754 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 755 + resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} 756 + cpu: [arm] 757 + os: [linux] 676 758 759 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 760 + resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} 761 + cpu: [arm] 762 + os: [linux] 677 763 764 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 765 + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} 766 + cpu: [arm64] 767 + os: [linux] 678 768 769 + '@rollup/rollup-linux-arm64-musl@4.56.0': 770 + resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} 771 + cpu: [arm64] 772 + os: [linux] 679 773 774 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 775 + resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} 776 + cpu: [loong64] 777 + os: [linux] 680 778 779 + '@rollup/rollup-linux-loong64-musl@4.56.0': 780 + resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} 781 + cpu: [loong64] 782 + os: [linux] 681 783 784 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 785 + resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} 786 + cpu: [ppc64] 787 + os: [linux] 682 788 789 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 790 + resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} 791 + cpu: [ppc64] 792 + os: [linux] 683 793 794 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 795 + resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} 796 + cpu: [riscv64] 797 + os: [linux] 684 798 799 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 800 + resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} 801 + cpu: [riscv64] 802 + os: [linux] 685 803 804 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 805 + resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} 806 + cpu: [s390x] 807 + os: [linux] 686 808 809 + '@rollup/rollup-linux-x64-gnu@4.56.0': 810 + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} 811 + cpu: [x64] 812 + os: [linux] 687 813 814 + '@rollup/rollup-linux-x64-musl@4.56.0': 815 + resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} 816 + cpu: [x64] 817 + os: [linux] 688 818 819 + '@rollup/rollup-openbsd-x64@4.56.0': 820 + resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} 821 + cpu: [x64] 822 + os: [openbsd] 689 823 824 + '@rollup/rollup-openharmony-arm64@4.56.0': 825 + resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} 826 + cpu: [arm64] 827 + os: [openharmony] 690 828 829 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 830 + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} 831 + cpu: [arm64] 832 + os: [win32] 691 833 834 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 835 + resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} 836 + cpu: [ia32] 837 + os: [win32] 692 838 839 + '@rollup/rollup-win32-x64-gnu@4.56.0': 840 + resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} 841 + cpu: [x64] 842 + os: [win32] 693 843 844 + '@rollup/rollup-win32-x64-msvc@4.56.0': 845 + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} 846 + cpu: [x64] 847 + os: [win32] 694 848 695 849 696 850 ··· 813 967 814 968 815 969 970 + '@types/estree@1.0.8': 971 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 816 972 973 + '@types/node@25.0.10': 974 + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} 817 975 976 + acorn@8.15.0: 977 + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 818 978 819 979 820 980 ··· 844 1004 845 1005 846 1006 1007 + bun-types@1.3.6: 1008 + resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==} 847 1009 1010 + caniuse-lite@1.0.30001766: 1011 + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} 848 1012 1013 + codemirror@6.0.2: 1014 + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} 849 1015 850 1016 851 1017 ··· 905 1071 906 1072 907 1073 1074 + domutils@3.2.2: 1075 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 908 1076 1077 + electron-to-chromium@1.5.278: 1078 + resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} 909 1079 1080 + enhanced-resolve@5.18.4: 1081 + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} 910 1082 911 1083 912 1084 ··· 1203 1375 1204 1376 1205 1377 1378 + prettier-plugin-svelte: 1379 + optional: true 1206 1380 1381 + prettier@3.8.1: 1382 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 1383 + engines: {node: '>=14'} 1384 + hasBin: true 1207 1385 1386 + resolve-pkg-maps@1.0.0: 1387 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 1208 1388 1389 + rollup@4.56.0: 1390 + resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} 1391 + engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1392 + hasBin: true 1209 1393 1210 1394 1211 1395 1212 1396 1213 1397 1214 1398 1399 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 1400 + hasBin: true 1215 1401 1216 - 1217 - 1218 - 1219 - 1220 - 1221 - 1222 - 1223 - 1224 - 1225 - 1226 - 1227 - 1228 - 1229 - 1230 - 1231 - 1232 - 1233 - 1234 - 1235 - 1236 - 1237 - 1238 - 1239 - 1240 - 1241 - 1242 - 1243 - 1244 - 1245 - 1246 - 1247 - 1248 - 1249 - 1250 - 1251 - 1252 - 1253 - 1254 - 1255 - 1256 - 1257 - 1258 - 1259 - 1260 - 1261 - 1262 - 1263 - 1264 - 1265 - 1266 - 1267 - 1268 - 1269 - 1270 - 1271 - 1272 - 1273 - 1274 - 1275 - 1276 - 1277 - 1278 - 1279 - 1280 - 1281 - 1282 - 1283 - 1284 - 1285 - 1286 - 1287 - 1288 - 1289 - 1290 - 1291 - 1292 - 1293 - 1294 - 1295 - 1296 - 1297 - 1298 - 1299 - 1300 - 1301 - 1302 - 1303 - 1304 - 1305 - 1306 - 1307 - 1308 - 1309 - 1310 - 1311 - 1312 - 1313 - 1314 - 1315 - 1316 - 1317 - 1318 - 1319 - 1320 - 1321 - 1322 - 1323 - 1324 - 1325 - 1326 - 1327 - 1328 - 1329 - 1330 - 1331 - 1332 - 1333 - 1334 - 1335 - 1336 - 1337 - 1338 - 1339 - 1340 - 1341 - 1342 - 1343 - 1344 - 1345 - 1346 - 1347 - 1348 - 1349 - 1350 - 1351 - 1352 - 1353 - 1354 - 1355 - 1356 - 1357 - 1358 - 1359 - 1360 - 1361 - 1362 - 1363 - 1364 - 1365 - 1366 - 1367 - 1368 - 1369 - 1370 - 1371 - 1372 - 1373 - 1374 - 1375 - 1376 - 1377 - 1378 - 1379 - 1380 - 1381 - 1382 - 1383 - 1384 - 1385 - 1386 - 1387 - 1388 - 1389 - 1390 - 1391 - 1392 - 1393 - 1394 - 1395 - 1396 - 1397 - 1398 - 1399 - 1400 - 1401 - 1402 - 1403 - 1404 - 1405 - 1406 - 1407 - 1408 - 1409 - resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==} 1402 + seroval-plugins@1.5.0: 1403 + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} 1410 1404 engines: {node: '>=10'} 1411 1405 peerDependencies: 1412 1406 seroval: ^1.0 1413 1407 1414 - seroval@1.3.2: 1415 - resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} 1408 + seroval@1.5.0: 1409 + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} 1416 1410 engines: {node: '>=10'} 1417 1411 1418 - solid-js@1.9.10: 1419 - 1420 - 1421 - 1422 - 1423 - 1424 - 1425 - 1426 - 1427 - 1428 - 1429 - 1430 - 1431 - 1432 - 1433 - 1434 - 1435 - 1436 - 1437 - 1438 - 1439 - 1440 - 1441 - 1442 - 1443 - 1444 - 1445 - 1446 - 1447 - 1448 - 1449 - 1450 - 1451 - 1452 - 1453 - 1454 - 1455 - 1456 - 1457 - 1458 - 1459 - 1460 - 1461 - 1462 - 1463 - 1464 - 1465 - 1466 - 1467 - 1468 - 1469 - 1470 - 1471 - 1472 - 1473 - 1474 - 1475 - 1476 - 1477 - 1478 - 1479 - 1480 - 1481 - 1482 - 1483 - 1484 - 1485 - 1486 - 1487 - 1488 - 1489 - 1490 - 1491 - 1492 - 1493 - 1494 - 1495 - 1496 - 1497 - 1498 - 1499 - 1500 - 1501 - 1502 - 1503 - 1504 - 1505 - 1506 - 1507 - 1508 - 1509 - 1510 - 1511 - 1512 - 1513 - 1514 - 1515 - 1516 - 1517 - 1518 - 1519 - 1520 - 1521 - 1522 - 1523 - 1524 - 1525 - 1526 - 1527 - 1528 - 1529 - 1412 + solid-js@1.9.11: 1413 + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} 1530 1414 1415 + solid-refresh@0.6.3: 1416 + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} 1531 1417 1532 1418 1533 1419 ··· 1729 1615 1730 1616 1731 1617 1618 + '@atcute/lexicons': 1.2.6 1619 + '@badrap/valita': 0.4.6 1732 1620 1621 + '@atcute/lexicon-doc@2.0.6': 1622 + dependencies: 1623 + '@atcute/identity': 1.1.3 1733 1624 1734 1625 1735 1626 ··· 2225 2116 2226 2117 2227 2118 2119 + '@noble/secp256k1@3.0.0': {} 2228 2120 2121 + '@rollup/rollup-android-arm-eabi@4.56.0': 2122 + optional: true 2229 2123 2124 + '@rollup/rollup-android-arm64@4.56.0': 2125 + optional: true 2230 2126 2127 + '@rollup/rollup-darwin-arm64@4.56.0': 2128 + optional: true 2231 2129 2130 + '@rollup/rollup-darwin-x64@4.56.0': 2131 + optional: true 2232 2132 2133 + '@rollup/rollup-freebsd-arm64@4.56.0': 2134 + optional: true 2233 2135 2136 + '@rollup/rollup-freebsd-x64@4.56.0': 2137 + optional: true 2234 2138 2139 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 2140 + optional: true 2235 2141 2142 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 2143 + optional: true 2236 2144 2145 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 2146 + optional: true 2237 2147 2148 + '@rollup/rollup-linux-arm64-musl@4.56.0': 2149 + optional: true 2238 2150 2151 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 2152 + optional: true 2239 2153 2154 + '@rollup/rollup-linux-loong64-musl@4.56.0': 2155 + optional: true 2240 2156 2157 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 2158 + optional: true 2241 2159 2160 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 2161 + optional: true 2242 2162 2163 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 2164 + optional: true 2243 2165 2166 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 2167 + optional: true 2244 2168 2169 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 2170 + optional: true 2245 2171 2172 + '@rollup/rollup-linux-x64-gnu@4.56.0': 2173 + optional: true 2246 2174 2175 + '@rollup/rollup-linux-x64-musl@4.56.0': 2176 + optional: true 2247 2177 2178 + '@rollup/rollup-openbsd-x64@4.56.0': 2179 + optional: true 2248 2180 2181 + '@rollup/rollup-openharmony-arm64@4.56.0': 2182 + optional: true 2249 2183 2184 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 2185 + optional: true 2250 2186 2187 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 2188 + optional: true 2251 2189 2190 + '@rollup/rollup-win32-x64-gnu@4.56.0': 2191 + optional: true 2252 2192 2193 + '@rollup/rollup-win32-x64-msvc@4.56.0': 2194 + optional: true 2253 2195 2196 + '@skyware/firehose@0.5.2': 2254 2197 2255 2198 2199 + '@atcute/cbor': 2.3.0 2200 + nanoevents: 9.1.0 2256 2201 2202 + '@solidjs/meta@0.29.4(solid-js@1.9.11)': 2203 + dependencies: 2204 + solid-js: 1.9.11 2257 2205 2206 + '@solidjs/router@0.15.4(solid-js@1.9.11)': 2207 + dependencies: 2208 + solid-js: 1.9.11 2258 2209 2210 + '@standard-schema/spec@1.1.0': {} 2259 2211 2260 2212 2261 2213 ··· 2315 2267 2316 2268 2317 2269 2270 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 2271 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 2318 2272 2273 + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2))': 2274 + dependencies: 2275 + '@tailwindcss/node': 4.1.18 2276 + '@tailwindcss/oxide': 4.1.18 2277 + tailwindcss: 4.1.18 2278 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2319 2279 2280 + '@types/babel__core@7.20.5': 2281 + dependencies: 2320 2282 2321 2283 2322 2284 ··· 2340 2302 2341 2303 2342 2304 2305 + '@types/estree@1.0.8': {} 2343 2306 2307 + '@types/node@25.0.10': 2308 + dependencies: 2309 + undici-types: 7.16.0 2344 2310 2345 2311 2346 2312 ··· 2350 2316 2351 2317 2352 2318 2319 + html-entities: 2.3.3 2320 + parse5: 7.3.0 2353 2321 2322 + babel-preset-solid@1.9.10(@babel/core@7.28.6)(solid-js@1.9.11): 2323 + dependencies: 2324 + '@babel/core': 7.28.6 2325 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.28.6) 2326 + optionalDependencies: 2327 + solid-js: 1.9.11 2354 2328 2329 + baseline-browser-mapping@2.9.17: {} 2355 2330 2356 2331 2357 2332 2333 + browserslist@4.28.1: 2334 + dependencies: 2335 + baseline-browser-mapping: 2.9.17 2336 + caniuse-lite: 1.0.30001766 2337 + electron-to-chromium: 1.5.278 2338 + node-releases: 2.0.27 2339 + update-browserslist-db: 1.2.3(browserslist@4.28.1) 2358 2340 2341 + bun-types@1.3.6: 2342 + dependencies: 2343 + '@types/node': 25.0.10 2359 2344 2345 + caniuse-lite@1.0.30001766: {} 2360 2346 2347 + codemirror@6.0.2: 2348 + dependencies: 2361 2349 2362 2350 2363 2351 ··· 2421 2409 2422 2410 2423 2411 2412 + domelementtype: 2.3.0 2413 + domhandler: 5.0.3 2424 2414 2415 + electron-to-chromium@1.5.278: {} 2425 2416 2417 + enhanced-resolve@5.18.4: 2418 + dependencies: 2426 2419 2427 2420 2428 2421 ··· 2645 2638 2646 2639 2647 2640 2641 + picocolors: 1.1.1 2642 + source-map-js: 1.2.1 2648 2643 2644 + prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): 2645 + dependencies: 2646 + prettier: 3.8.1 2647 + typescript: 5.9.3 2649 2648 2649 + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1): 2650 + dependencies: 2651 + prettier: 3.8.1 2652 + optionalDependencies: 2653 + prettier-plugin-organize-imports: 4.3.0(prettier@3.8.1)(typescript@5.9.3) 2650 2654 2655 + prettier@3.8.1: {} 2651 2656 2657 + resolve-pkg-maps@1.0.0: 2658 + optional: true 2652 2659 2660 + rollup@4.56.0: 2661 + dependencies: 2662 + '@types/estree': 1.0.8 2663 + optionalDependencies: 2664 + '@rollup/rollup-android-arm-eabi': 4.56.0 2665 + '@rollup/rollup-android-arm64': 4.56.0 2666 + '@rollup/rollup-darwin-arm64': 4.56.0 2667 + '@rollup/rollup-darwin-x64': 4.56.0 2668 + '@rollup/rollup-freebsd-arm64': 4.56.0 2669 + '@rollup/rollup-freebsd-x64': 4.56.0 2670 + '@rollup/rollup-linux-arm-gnueabihf': 4.56.0 2671 + '@rollup/rollup-linux-arm-musleabihf': 4.56.0 2672 + '@rollup/rollup-linux-arm64-gnu': 4.56.0 2673 + '@rollup/rollup-linux-arm64-musl': 4.56.0 2674 + '@rollup/rollup-linux-loong64-gnu': 4.56.0 2675 + '@rollup/rollup-linux-loong64-musl': 4.56.0 2676 + '@rollup/rollup-linux-ppc64-gnu': 4.56.0 2677 + '@rollup/rollup-linux-ppc64-musl': 4.56.0 2678 + '@rollup/rollup-linux-riscv64-gnu': 4.56.0 2679 + '@rollup/rollup-linux-riscv64-musl': 4.56.0 2680 + '@rollup/rollup-linux-s390x-gnu': 4.56.0 2681 + '@rollup/rollup-linux-x64-gnu': 4.56.0 2682 + '@rollup/rollup-linux-x64-musl': 4.56.0 2683 + '@rollup/rollup-openbsd-x64': 4.56.0 2684 + '@rollup/rollup-openharmony-arm64': 4.56.0 2685 + '@rollup/rollup-win32-arm64-msvc': 4.56.0 2686 + '@rollup/rollup-win32-ia32-msvc': 4.56.0 2687 + '@rollup/rollup-win32-x64-gnu': 4.56.0 2688 + '@rollup/rollup-win32-x64-msvc': 4.56.0 2689 + fsevents: 2.3.3 2653 2690 2691 + sax@1.4.4: {} 2654 2692 2693 + semver@6.3.1: {} 2655 2694 2695 + seroval-plugins@1.5.0(seroval@1.5.0): 2696 + dependencies: 2697 + seroval: 1.5.0 2656 2698 2699 + seroval@1.5.0: {} 2657 2700 2701 + solid-js@1.9.11: 2702 + dependencies: 2703 + csstype: 3.2.3 2704 + seroval: 1.5.0 2705 + seroval-plugins: 1.5.0(seroval@1.5.0) 2658 2706 2707 + solid-refresh@0.6.3(solid-js@1.9.11): 2708 + dependencies: 2709 + '@babel/generator': 7.28.6 2710 + '@babel/helper-module-imports': 7.28.6 2711 + '@babel/types': 7.28.6 2712 + solid-js: 1.9.11 2713 + transitivePeerDependencies: 2714 + - supports-color 2659 2715 2660 2716 2661 2717 ··· 2701 2757 2702 2758 2703 2759 2704 - semver@6.3.1: {} 2760 + escalade: 3.2.0 2761 + picocolors: 1.1.1 2705 2762 2706 - seroval-plugins@1.3.3(seroval@1.3.2): 2763 + vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2707 2764 dependencies: 2708 - seroval: 1.3.2 2709 - 2710 - seroval@1.3.2: {} 2765 + '@babel/core': 7.28.6 2766 + '@types/babel__core': 7.20.5 2767 + babel-preset-solid: 1.9.10(@babel/core@7.28.6)(solid-js@1.9.11) 2768 + merge-anything: 5.1.7 2769 + solid-js: 1.9.11 2770 + solid-refresh: 0.6.3(solid-js@1.9.11) 2771 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2772 + vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 2773 + transitivePeerDependencies: 2774 + - supports-color 2711 2775 2712 - solid-js@1.9.10: 2776 + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2): 2713 2777 dependencies: 2714 - csstype: 3.2.3 2715 - seroval: 1.3.2 2716 - seroval-plugins: 1.3.3(seroval@1.3.2) 2778 + esbuild: 0.27.2 2779 + fdir: 6.5.0(picomatch@4.0.3) 2780 + picomatch: 4.0.3 2781 + postcss: 8.5.6 2782 + rollup: 4.56.0 2783 + tinyglobby: 0.2.15 2784 + optionalDependencies: 2785 + '@types/node': 25.0.10 2786 + fsevents: 2.3.3 2787 + jiti: 2.6.1 2788 + lightningcss: 1.30.2 2789 + tsx: 4.19.2 2790 + 2791 + vitefu@1.1.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2792 + optionalDependencies: 2793 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2794 + 2795 + w3c-keyname@2.2.8: {} 2717 2796 2718 - solid-refresh@0.6.3(solid-js@1.9.10): 2719 - dependencies:
+338 -1
src/views/collection.tsx
··· 40 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 41 trigger={ 42 42 <> 43 - <span class="shrink-0 text-sm text-blue-500 dark:text-blue-400">{props.record.rkey}</span> 43 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 44 + {props.record.rkey} 45 + </span> 44 46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 45 47 {props.record.cid} 46 48 </span> 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 316 + <Button 317 + onClick={deleteRecords} 318 + classList={{ 319 + "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!": 320 + recreate(), 321 + "text-white! border-none! bg-red-500! hover:bg-red-600! active:bg-red-700!": 322 + !recreate(), 323 + }} 324 + > 325 + {recreate() ? "Recreate" : "Delete"} 326 + </Button> 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + <Button onClick={() => refetch()}>Load more</Button> 378 + </Show> 379 + <Show when={response.loading}> 380 + <div class="iconify lucide--loader-circle w-20 animate-spin text-lg" /> 381 + </Show> 382 + </Show> 383 + </div>
+3 -3
src/auth/scope-selector.tsx
··· 44 44 }; 45 45 46 46 return ( 47 - <div class="flex flex-col gap-y-2"> 48 - <div class="mb-1 flex items-center gap-2"> 47 + <div class="flex flex-col gap-y-3"> 48 + <div class="flex items-center gap-2"> 49 49 <button 50 50 onclick={props.onCancel} 51 51 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 77 77 </div> 78 78 <button 79 79 onclick={handleConfirm} 80 - class="mt-2 grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 80 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 81 81 > 82 82 Continue 83 83 </button>
-14
src/utils/templates.ts
··· 37 37 link: `https://pinksea.art/${uri.repo}`, 38 38 icon: "i-pinksea", 39 39 }), 40 - "blue.linkat.board": (uri) => ({ 41 - label: "Linkat", 42 - link: `https://linkat.blue/${uri.repo}`, 43 - }), 44 40 "sh.tangled.actor.profile": (uri) => ({ 45 41 label: "Tangled", 46 42 link: `https://tangled.org/${uri.repo}`, ··· 51 47 link: `https://tangled.org/${uri.repo}/${record.name}`, 52 48 icon: "i-tangled", 53 49 }), 54 - "pub.leaflet.document": (uri) => ({ 55 - label: "Leaflet", 56 - link: `https://leaflet.pub/p/${uri.repo}/${uri.rkey}`, 57 - icon: "iconify-color i-leaflet", 58 - }), 59 - "pub.leaflet.publication": (uri) => ({ 60 - label: "Leaflet", 61 - link: `https://leaflet.pub/lish/${uri.repo}/${uri.rkey}`, 62 - icon: "iconify-color i-leaflet", 63 - }), 64 50 };
-12
src/utils/types/lexicons.ts
··· 17 17 AppBskyLabelerService, 18 18 ChatBskyActorDeclaration, 19 19 } from "@atcute/bluesky"; 20 - import { 21 - PubLeafletComment, 22 - PubLeafletDocument, 23 - PubLeafletGraphSubscription, 24 - PubLeafletPublication, 25 - } from "@atcute/leaflet"; 26 20 import { 27 21 ShTangledActorProfile, 28 22 ShTangledFeedStar, ··· 85 79 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 86 80 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 87 81 "sh.tangled.knot": ShTangledKnot.mainSchema, 88 - 89 - // Leaflet 90 - "pub.leaflet.comment": PubLeafletComment.mainSchema, 91 - "pub.leaflet.document": PubLeafletDocument.mainSchema, 92 - "pub.leaflet.graph.subscription": PubLeafletGraphSubscription.mainSchema, 93 - "pub.leaflet.publication": PubLeafletPublication.mainSchema, 94 82 };
+1
.gitignore
··· 2 2 dist 3 3 .env 4 4 .DS_Store 5 + public/oauth-client-metadata.json
-13
public/oauth-client-metadata.json
··· 1 - { 2 - "client_id": "https://pdsls.dev/oauth-client-metadata.json", 3 - "client_name": "PDSls", 4 - "client_uri": "https://pdsls.dev", 5 - "logo_uri": "https://pdsls.dev/favicon.ico", 6 - "redirect_uris": ["https://pdsls.dev/"], 7 - "scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 8 - "grant_types": ["authorization_code", "refresh_token"], 9 - "response_types": ["code"], 10 - "token_endpoint_auth_method": "none", 11 - "application_type": "web", 12 - "dpop_bound_access_tokens": true 13 - }
+35
scripts/generate-oauth-metadata.js
··· 1 + import { mkdirSync, writeFileSync } from "fs"; 2 + import { dirname } from "path"; 3 + import { fileURLToPath } from "url"; 4 + 5 + const __filename = fileURLToPath(import.meta.url); 6 + const __dirname = dirname(__filename); 7 + 8 + const domain = process.env.APP_DOMAIN || "pdsls.dev"; 9 + const protocol = process.env.APP_PROTOCOL || "https"; 10 + const baseUrl = `${protocol}://${domain}`; 11 + 12 + const metadata = { 13 + client_id: `${baseUrl}/oauth-client-metadata.json`, 14 + client_name: "PDSls", 15 + client_uri: baseUrl, 16 + logo_uri: `${baseUrl}/favicon.ico`, 17 + redirect_uris: [`${baseUrl}/`], 18 + scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 19 + grant_types: ["authorization_code", "refresh_token"], 20 + response_types: ["code"], 21 + token_endpoint_auth_method: "none", 22 + application_type: "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + 26 + const outputPath = `${__dirname}/../public/oauth-client-metadata.json`; 27 + 28 + try { 29 + mkdirSync(dirname(outputPath), { recursive: true }); 30 + writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + "\n"); 31 + console.log(`Generated OAuth metadata for ${baseUrl}`); 32 + } catch (error) { 33 + console.error("Failed to generate OAuth metadata:", error); 34 + process.exit(1); 35 + }
public/avatar/bad-example.com.jpg

This is a binary file and will not be displayed.

public/avatar/futur.blue.jpg

This is a binary file and will not be displayed.

public/avatar/hailey.at.jpg

This is a binary file and will not be displayed.

public/avatar/jaz.sh.jpg

This is a binary file and will not be displayed.

public/avatar/jcsalterego.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/juli.ee.jpg

This is a binary file and will not be displayed.

public/avatar/mary.my.id.jpg

This is a binary file and will not be displayed.

public/avatar/retr0.id.jpg

This is a binary file and will not be displayed.

public/avatar/aylac.top.jpg

This is a binary file and will not be displayed.

public/avatar/computer.fish.jpg

This is a binary file and will not be displayed.

public/avatar/dreary.blacksky.app.jpg

This is a binary file and will not be displayed.

public/avatar/emilia.wtf.jpg

This is a binary file and will not be displayed.

public/avatar/futanari.observer.jpg

This is a binary file and will not be displayed.

public/avatar/mofu.run.jpg

This is a binary file and will not be displayed.

public/avatar/natalie.sh.jpg

This is a binary file and will not be displayed.

public/avatar/nekomimi.pet.jpg

This is a binary file and will not be displayed.

public/avatar/nullekko.moe.jpg

This is a binary file and will not be displayed.

public/avatar/paizuri.moe.jpg

This is a binary file and will not be displayed.

public/avatar/quilling.dev.jpg

This is a binary file and will not be displayed.

public/avatar/rainy.pet.jpg

This is a binary file and will not be displayed.

public/avatar/sapphic.moe.jpg

This is a binary file and will not be displayed.

public/avatar/blooym.dev.jpg

This is a binary file and will not be displayed.

public/avatar/isabelroses.com.jpg

This is a binary file and will not be displayed.

public/avatar/isuggest.selfce.st.jpg

This is a binary file and will not be displayed.

public/avatar/anyaustin.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/claire.on-her.computer.jpg

This is a binary file and will not be displayed.

public/avatar/cwonus.org.jpg

This is a binary file and will not be displayed.

public/avatar/number-one-warned.rat.mom.jpg

This is a binary file and will not be displayed.

public/avatar/olaren.dev.jpg

This is a binary file and will not be displayed.

public/avatar/coil-habdle.ebil.club.jpg

This is a binary file and will not be displayed.

+2 -7
package.json
··· 10 10 "build": "vite build", 11 11 "serve": "vite preview" 12 12 }, 13 - "pnpm": { 14 - "overrides": { 15 - "seroval": "^1.4.1" 16 - } 17 - }, 18 13 "devDependencies": { 19 14 "@iconify-json/lucide": "^1.2.86", 20 15 "@iconify/tailwind4": "^1.2.1", 21 16 "@tailwindcss/vite": "^4.1.18", 22 - "prettier": "^3.8.0", 17 + "prettier": "^3.8.1", 23 18 "prettier-plugin-organize-imports": "^4.3.0", 24 19 "prettier-plugin-tailwindcss": "^0.7.2", 25 20 "tailwindcss": "^4.1.18", ··· 60 55 "@solidjs/router": "^0.15.4", 61 56 "codemirror": "^6.0.2", 62 57 "native-file-system-adapter": "^3.0.1", 63 - "solid-js": "^1.9.10" 58 + "solid-js": "^1.9.11" 64 59 }, 65 60 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 66 61 }
+3 -1
src/components/button.tsx
··· 6 6 class?: string; 7 7 classList?: Record<string, boolean | undefined>; 8 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 + ontouchstart?: (e: TouchEvent) => void; 9 10 children?: JSX.Element; 10 11 } 11 12 ··· 16 17 disabled={props.disabled ?? false} 17 18 class={ 18 19 props.class ?? 19 - "dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 20 + "dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300" 20 21 } 21 22 classList={props.classList} 22 23 onClick={props.onClick} 24 + ontouchstart={props.ontouchstart} 23 25 > 24 26 {props.children} 25 27 </button>
+4 -2
src/views/labels.tsx
··· 277 277 <Button 278 278 onClick={handleLoadMore} 279 279 disabled={loading()} 280 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-20 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 280 + classList={{ "w-20 justify-center": true }} 281 281 > 282 282 <Show 283 283 when={!loading()} 284 - fallback={<span class="iconify lucide--loader-circle animate-spin" />} 284 + fallback={ 285 + <span class="iconify lucide--loader-circle animate-spin text-base" /> 286 + } 285 287 > 286 288 Load more 287 289 </Show>
+13 -5
src/views/pds.tsx
··· 253 253 <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 pt-2 pb-4"> 254 254 <div class="flex flex-col items-center gap-1 pb-2"> 255 255 <p>{repos()?.length} loaded</p> 256 - <Show when={!response.loading && cursor()}> 257 - <Button onClick={() => refetch()}>Load more</Button> 258 - </Show> 259 - <Show when={response.loading}> 260 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 256 + <Show when={cursor()}> 257 + <Button 258 + onClick={() => refetch()} 259 + disabled={response.loading} 260 + classList={{ "w-20 justify-center": true }} 261 + > 262 + <Show 263 + when={!response.loading} 264 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 265 + > 266 + Load more 267 + </Show> 268 + </Button> 261 269 </Show> 262 270 </div> 263 271 </div>