atmosphere explorer pds.ls
tool typescript atproto

Compare changes

Choose any two refs to compare.

+4996 -1299
+70 -10
src/components/create.tsx
··· 1 2 3 4 5 6 ··· 36 37 38 39 40 41 42 43 44 45 46 ··· 142 143 144 145 146 147 ··· 463 464 465 466 467 468 ··· 481 482 483 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
··· 1 2 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"; 20 21 22 ··· 52 53 54 55 + } 56 + }); 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; 62 63 + const key = props.create ? "n" : "e"; 64 + if (ev.key === key) { 65 + ev.preventDefault(); 66 67 + if (openDialog() && isMinimized()) { 68 + setIsMinimized(false); 69 + } else if (!openDialog() && !document.querySelector("[data-modal]")) { 70 + setOpenDialog(true); 71 + } 72 + } 73 + }; 74 75 + window.addEventListener("keydown", keyEvent); 76 + onCleanup(() => window.removeEventListener("keydown", keyEvent)); 77 + }); 78 79 + const defaultPlaceholder = () => { 80 + return { 81 + $type: "app.bsky.feed.post", 82 83 84 ··· 180 181 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) { 201 202 203 ··· 519 520 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 529 530 531 ··· 544 545 546 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"; 2 import { Did } from "@atcute/lexicons"; 3 import { 4 finalizeAuthorization, ··· 20 21 export const getAvatar = async (did: Did): Promise<string | undefined> => { 22 const rpc = new Client({ 23 - handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 24 }); 25 const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } }); 26 if (res.ok) {
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 import { Did } from "@atcute/lexicons"; 3 import { 4 finalizeAuthorization, ··· 20 21 export const getAvatar = async (did: Did): Promise<string | undefined> => { 22 const rpc = new Client({ 23 + handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 24 }); 25 const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } }); 26 if (res.ok) {
public/favicon.ico

This is a binary file and will not be displayed.

+1 -4
index.html
··· 7 8 9 10 - 11 <meta property="description" content="Browse the public data on atproto" /> 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 <link rel="preconnect" href="https://fonts.bunny.net" /> 17 <link href="https://fonts.bunny.net/css?family=roboto-mono:400" rel="stylesheet" /> 18 <link href="https://fonts.cdnfonts.com/css/pecita" rel="stylesheet" />
··· 7 8 9 10 + <meta property="og:description" content="Browse the public data on atproto" /> 11 <meta property="description" content="Browse the public data on atproto" /> 12 <link rel="manifest" href="/manifest.json" /> 13 <link rel="preconnect" href="https://fonts.bunny.net" /> 14 <link href="https://fonts.bunny.net/css?family=roboto-mono:400" rel="stylesheet" /> 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 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 import { basicSetup, EditorView } from "codemirror"; 9 import { onCleanup, onMount } from "solid-js"; 10 - import { editorInstance } from "./create"; 11 12 const Editor = (props: { content: string }) => { 13 let editorDiv!: HTMLDivElement;
··· 7 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 import { basicSetup, EditorView } from "codemirror"; 9 import { onCleanup, onMount } from "solid-js"; 10 + import { editorInstance } from "./create/state"; 11 12 const Editor = (props: { content: string }) => { 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 6 7 8 9 10 11 ··· 16 17 18 19 20 21 22 ··· 40 41 42 43 44 45 46 47 ··· 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 </div> 273 <div class="flex items-center gap-1.5 text-sm font-medium"> 274 <Show when={validLog() === true}> 275 - <span class="iconify lucide--check-circle-2 text-green-500 dark:text-green-400"></span> 276 <span>Valid log</span> 277 </Show> 278 <Show when={validLog() === false}>
··· 5 6 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"; 12 13 + type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; 14 15 16 ··· 21 22 23 24 + !activePlcEvent() || diffs.some((d) => d.type.startsWith(activePlcEvent()!)); 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); 31 32 33 ··· 51 52 53 54 + } 55 + }); 56 57 + const FilterButton = (props: { event: PlcEvent; label: string }) => { 58 + const isActive = () => activePlcEvent() === props.event; 59 + const toggleFilter = () => setActivePlcEvent(isActive() ? undefined : props.event); 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 + }; 75 76 77 ··· 250 251 252 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> 262 </div> 263 <div class="flex items-center gap-1.5 text-sm font-medium"> 264 <Show when={validLog() === true}> 265 + <span class="iconify lucide--check text-green-600 dark:text-green-400"></span> 266 <span>Valid log</span> 267 </Show> 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 - 12 13 14 15 - . 16 - </span> 17 </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> 21 </div> 22 - <div class="flex items-center gap-1"> 23 - <div class="iconify lucide--radio-tower" /> 24 - <span>Jetstream and firehose streaming.</span> 25 </div> 26 - <div class="flex items-center gap-1"> 27 - <div class="iconify lucide--link" /> 28 - <span> 29 - 30 - 31 - 32 - 33 - 34 - 35 36 37 - . 38 - </span> 39 </div> 40 - <div class="flex items-center gap-1"> 41 - <div class="iconify lucide--tag" /> 42 - <span>Query labels from moderation services.</span>
··· 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 + }; 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 + ]; 60 61 + const profiles = [...allExampleProfiles].sort(() => Math.random() - 0.5).slice(0, 3); 62 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> 81 </div> 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> 112 </div> 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> 119 </div> 120 + </div> 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> 164 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> 192 </div> 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 /> 30 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" 33 classList={{ 34 - "bg-neutral-50 dark:bg-dark-300 border-[0.5px] border-neutral-300 dark:border-neutral-700 shadow-md": 35 filterStuck(), 36 "bg-transparent border-transparent shadow-none": !filterStuck(), 37 }}
··· 29 /> 30 31 <div 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 classList={{ 34 + "bg-neutral-50 dark:bg-dark-300 border-neutral-300 dark:border-neutral-700 shadow-md": 35 filterStuck(), 36 "bg-transparent border-transparent shadow-none": !filterStuck(), 37 }}
+105 -51
src/components/navbar.tsx
··· 1 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 10 11 ··· 18 e.stopPropagation(); 19 addToClipboard(props.content); 20 }} 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`} 22 aria-label="Copy to clipboard" 23 > 24 - <span class="iconify lucide--link"></span> 25 - 26 - 27 - 28 29 30 31 export const NavBar = (props: { params: Params }) => { 32 const [handle, setHandle] = createSignal(props.params.repo); 33 - const [showHandle, setShowHandle] = createSignal(localStorage.showHandle === "true"); 34 35 createEffect(() => { 36 if (pds() !== undefined && props.params.repo) { ··· 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 ··· 61 62 63 64 65 66 ··· 73 74 75 76 77 78 ··· 86 87 88 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> 123 </Tooltip> 124 - <CopyButton content={props.params.repo!} label="Copy DID" /> 125 </div> 126 - </div> 127 - </Show> 128 -
··· 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"; 9 10 + export const [pds, setPDS] = createSignal<string>(); 11 12 13 ··· 20 e.stopPropagation(); 21 addToClipboard(props.content); 22 }} 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`} 24 aria-label="Copy to clipboard" 25 > 26 + <span class="iconify lucide--copy"></span> 27 + </button> 28 + </Tooltip> 29 + </Show> 30 31 32 33 export const NavBar = (props: { params: Params }) => { 34 const [handle, setHandle] = createSignal(props.params.repo); 35 36 createEffect(() => { 37 if (pds() !== undefined && props.params.repo) { ··· 40 41 42 43 + } 44 + }); 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 + }); 51 52 + return ( 53 + <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 54 + {/* PDS Level */} 55 56 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={ 72 73 74 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> 83 84 85 ··· 93 94 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> 137 138 139 ··· 146 147 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> 156 157 158 ··· 166 167 168 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> 171 </Tooltip> 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> 180 </div> 181 + <CopyButton 182 + content={`at://${props.params.repo}/${props.params.collection}/${props.params.rkey}`}
+142 -173
src/components/backlinks.tsx
··· 1 import * as TID from "@atcute/tid"; 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"; 10 import { localDateFromTimestamp } from "../utils/date.js"; 11 import { Button } from "./button.jsx"; 12 13 - type Backlink = { 14 path: string; 15 counts: { distinct_dids: number; records: number }; 16 }; 17 18 - const linksBySource = (links: Record<string, any>) => { 19 - let out: Record<string, Backlink[]> = {}; 20 Object.keys(links) 21 .toSorted() 22 .forEach((collection) => { ··· 24 Object.keys(paths) 25 .toSorted() 26 .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] }]; 30 }); 31 }); 32 - return out; 33 }; 34 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); 42 43 - const [show, setShow] = createSignal<{ 44 - collection: string; 45 - path: string; 46 - showDids: boolean; 47 - } | null>(); 48 49 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> 126 </div> 127 - )} 128 - </For> 129 </Show> 130 - </div> 131 ); 132 }; 133 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); 160 }); 161 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 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> 195 )} 196 </For> 197 </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} 206 /> 207 - </Show> 208 </Show> 209 - </Show> 210 ); 211 }; 212
··· 1 import * as TID from "@atcute/tid"; 2 import { createResource, createSignal, For, onMount, Show } from "solid-js"; 3 + import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../utils/api.js"; 4 import { localDateFromTimestamp } from "../utils/date.js"; 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"; 9 10 + type BacklinksProps = { 11 + target: string; 12 + collection: string; 13 + path: string; 14 + }; 15 + 16 + type BacklinkEntry = { 17 + collection: string; 18 path: string; 19 counts: { distinct_dids: number; records: number }; 20 }; 21 22 + const flattenLinks = (links: Record<string, any>): BacklinkEntry[] => { 23 + const entries: BacklinkEntry[] = []; 24 Object.keys(links) 25 .toSorted() 26 .forEach((collection) => { ··· 28 Object.keys(paths) 29 .toSorted() 30 .forEach((path) => { 31 + if (paths[path].records > 0) { 32 + entries.push({ collection, path, counts: paths[path] }); 33 + } 34 }); 35 }); 36 + return entries; 37 }; 38 39 + const BacklinkRecords = (props: BacklinksProps & { cursor?: string }) => { 40 + const [links, setLinks] = createSignal<LinksWithRecords>(); 41 + const [more, setMore] = createSignal(false); 42 43 + onMount(async () => { 44 + const res = await getRecordBacklinks(props.target, props.collection, props.path, props.cursor); 45 + setLinks(res); 46 + }); 47 48 return ( 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> 98 </div> 99 + } 100 + > 101 + <BacklinkRecords 102 + target={props.target} 103 + collection={props.collection} 104 + path={props.path} 105 + cursor={links()!.cursor} 106 + /> 107 + </Show> 108 </Show> 109 + </Show> 110 ); 111 }; 112 113 + const Backlinks = (props: { target: string }) => { 114 + const [response] = createResource(async () => { 115 + const res = await getAllBacklinks(props.target); 116 + return flattenLinks(res.links); 117 }); 118 119 return ( 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 + /> 133 )} 134 </For> 135 </Show> 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() }} 170 /> 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> 177 </Show> 178 + </div> 179 ); 180 }; 181
+27 -7
src/auth/login.tsx
··· 29 30 31 32 33 34 35 36 37 38 ··· 46 47 48 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"
··· 29 30 31 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" 42 43 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" 56 57 58 ··· 66 67 68 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 disabled={props.disabled} 26 required={props.required} 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 " + 29 props.class 30 } 31 onInput={props.onInput}
··· 25 disabled={props.disabled} 26 required={props.required} 27 class={ 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 props.class 30 } 31 onInput={props.onInput}
+8 -7
src/components/dropdown.tsx
··· 69 70 71 72 73 74 - 75 - export const ActionMenu = (props: { 76 - label: string; 77 - icon: string; 78 - onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 79 - }) => { 80 return ( 81 <button 82 - onClick={props.onClick} 83 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 <Show when={props.icon}>
··· 69 70 71 72 + ); 73 + }; 74 75 + export const ActionMenu = (props: { label: string; icon: string; onClick: () => void }) => { 76 + const ctx = useContext(MenuContext); 77 78 return ( 79 <button 80 + onClick={() => { 81 + props.onClick(); 82 + ctx?.setShowMenu(false); 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" 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 9 export const ThemeSelection = () => { 10 const [theme, setTheme] = createSignal( 11 - localStorage.getItem("theme") === null ? "system" 12 : localStorage.theme === "dark" ? "dark" 13 : "light", 14 ); ··· 18 document.documentElement.classList.toggle( 19 "dark", 20 newTheme === "dark" || 21 - (newTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches), 22 ); 23 - if (newTheme === "system") localStorage.removeItem("theme"); 24 else localStorage.theme = newTheme; 25 }; 26 27 - const ThemeOption = (props: { theme: string; icon: string; label: string }) => { 28 return ( 29 <button 30 classList={{ 31 - "flex items-center gap-2 rounded-xl border px-3 py-2": true, 32 "bg-neutral-200/60 border-neutral-300 dark:border-neutral-500 dark:bg-neutral-700": 33 theme() === props.theme, 34 "border-neutral-200 dark:border-neutral-600 hover:bg-neutral-200/30 dark:hover:bg-neutral-800": ··· 36 }} 37 onclick={() => updateTheme(props.theme)} 38 > 39 - <span class={"iconify " + props.icon}></span> 40 - <span>{props.label}</span> 41 </button> 42 ); 43 }; 44 45 return ( 46 - <div class="flex flex-col gap-0.5"> 47 - <label class="select-none">Theme</label> 48 <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" /> 52 </div> 53 </div> 54 );
··· 8 9 export const ThemeSelection = () => { 10 const [theme, setTheme] = createSignal( 11 + localStorage.getItem("theme") === null ? "auto" 12 : localStorage.theme === "dark" ? "dark" 13 : "light", 14 ); ··· 18 document.documentElement.classList.toggle( 19 "dark", 20 newTheme === "dark" || 21 + (newTheme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches), 22 ); 23 + if (newTheme === "auto") localStorage.removeItem("theme"); 24 else localStorage.theme = newTheme; 25 }; 26 27 + const ThemeOption = (props: { theme: string; label: string }) => { 28 return ( 29 <button 30 classList={{ 31 + "flex items-center min-w-21 justify-center rounded-xl border px-3 py-2": true, 32 "bg-neutral-200/60 border-neutral-300 dark:border-neutral-500 dark:bg-neutral-700": 33 theme() === props.theme, 34 "border-neutral-200 dark:border-neutral-600 hover:bg-neutral-200/30 dark:hover:bg-neutral-800": ··· 36 }} 37 onclick={() => updateTheme(props.theme)} 38 > 39 + {props.label} 40 </button> 41 ); 42 }; 43 44 return ( 45 + <div class="flex flex-col gap-1"> 46 + <label class="font-medium select-none">Theme</label> 47 <div class="flex gap-2"> 48 + <ThemeOption theme="auto" label="Auto" /> 49 + <ThemeOption theme="light" label="Light" /> 50 + <ThemeOption theme="dark" label="Dark" /> 51 </div> 52 </div> 53 );
public/ribbon.webp

This is a binary file and will not be displayed.

+123 -119
src/views/stream.tsx
··· 1 import { Firehose } from "@skyware/firehose"; 2 import { A, useLocation, useSearchParams } from "@solidjs/router"; 3 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 4 import { Button } from "../components/button"; ··· 169 }); 170 171 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"}> 206 <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" 214 /> 215 </label> 216 - </Show> 217 - <Show when={streamType === "jetstream"}> 218 <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" 226 /> 227 </label> 228 </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 <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> 285 </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} /> 297 </div> 298 - )} 299 - </For> 300 </div> 301 - </div> 302 ); 303 }; 304
··· 1 import { Firehose } from "@skyware/firehose"; 2 + import { Title } from "@solidjs/meta"; 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../components/button"; ··· 170 }); 171 172 return ( 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()}> 195 <label class="flex items-center justify-end gap-x-1"> 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" 206 /> 207 </label> 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> 232 <label class="flex items-center justify-end gap-x-1"> 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" 239 /> 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> 254 </Show> 255 <Show when={connected()}> 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> 268 </Show> 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> 289 </div> 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> 304 </div> 305 + </> 306 ); 307 }; 308
+10 -5
src/components/tooltip.tsx
··· 1 2 3 - 4 - 5 - 6 - 7 <Show when={!isTouchDevice}> 8 <span 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`} 11 > 12 {props.text} 13 </span>
··· 1 2 + import { isTouchDevice } from "../layout"; 3 4 + const Tooltip = (props: { text: string; children: JSX.Element }) => ( 5 + <span class="group/tooltip relative inline-flex items-center"> 6 + {props.children} 7 <Show when={!isTouchDevice}> 8 <span 9 style={`transform: translate(-50%, 28px)`} 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 > 12 {props.text} 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 progress?: number; 8 total?: number; 9 type?: "info" | "success" | "error"; 10 }; 11 12 const [notifications, setNotifications] = createStore<Notification[]>([]); ··· 48 "animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id), 49 "animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id), 50 }} 51 - onClick={() => removeNotification(notification.id)} 52 > 53 <div class="flex items-center gap-2 text-sm"> 54 <Show when={notification.progress !== undefined}> ··· 82 {notification.progress}% 83 </div> 84 </Show> 85 </div> 86 </Show> 87 </div>
··· 7 progress?: number; 8 total?: number; 9 type?: "info" | "success" | "error"; 10 + onCancel?: () => void; 11 }; 12 13 const [notifications, setNotifications] = createStore<Notification[]>([]); ··· 49 "animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id), 50 "animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id), 51 }} 52 + onClick={() => 53 + notification.progress === undefined && removeNotification(notification.id) 54 + } 55 > 56 <div class="flex items-center gap-2 text-sm"> 57 <Show when={notification.progress !== undefined}> ··· 85 {notification.progress}% 86 </div> 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> 99 </div> 100 </Show> 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 21 22 23 24 25 ··· 32 33 34 35 36 37 38 ··· 86 87 88 89 90 91 92 ··· 122 123 124 125 126 127 ··· 295 296 297 298 299 300 ··· 330 331 332 333 334 335 336 337 ··· 406 407 408 409 410 411 412 413 414 415 416 417 ··· 488 489 490 491 492 493 ··· 512 513 514 515 516 517 ··· 528 529 530 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) => ( 575 <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> 578 <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>
··· 20 21 22 23 + MenuSeparator, 24 + NavMenu, 25 + } from "../components/dropdown.jsx"; 26 + import { Favicon } from "../components/favicon.jsx"; 27 + import { 28 + addNotification, 29 + removeNotification, 30 31 32 ··· 39 40 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"; 46 47 + export const RepoView = () => { 48 + const params = useParams(); 49 50 51 ··· 99 100 101 102 + }; 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 + }; 109 110 111 ··· 141 142 143 144 + if (!pds) { 145 + setError("Missing PDS"); 146 + return {}; 147 + } 148 149 150 ··· 318 319 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> 328 329 330 ··· 360 361 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 370 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" 379 380 381 ··· 450 451 452 453 + )} 454 + > 455 + {(authority) => { 456 + const isHighlighted = () => location.hash === `#collections:${authority}`; 457 458 + return ( 459 460 461 462 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) => 484 485 486 ··· 557 558 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" 586 587 588 ··· 607 608 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> 617 618 619 ··· 630 631 632 633 + <For each={rotationKeys()}> 634 + {(key) => ( 635 <div class="grid grid-cols-[auto_1fr] items-center gap-x-1 text-sm text-neutral-700 dark:text-neutral-300"> 636 + <span class="iconify lucide--key-round"></span> 637 + <span>{detectDidKeyType(key)}</span> 638 <span></span> 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 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 export const [sessions, setSessions] = createStore<Sessions>();
··· 12 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 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"; 2 import { didDocumentResolver, handleResolver } from "../utils/api"; 3 4 configureOAuth({ 5 metadata: { 6 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 7 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 8 }, 9 - identityResolver: defaultIdentityResolver({ 10 handleResolver: handleResolver, 11 - didDocumentResolver: didDocumentResolver, 12 }), 13 });
··· 1 + import { LocalActorResolver } from "@atcute/identity-resolver"; 2 + import { configureOAuth } from "@atcute/oauth-browser-client"; 3 import { didDocumentResolver, handleResolver } from "../utils/api"; 4 5 + const reactiveDidDocumentResolver = { 6 + resolve: async (did: string) => didDocumentResolver().resolve(did as any), 7 + }; 8 + 9 configureOAuth({ 10 metadata: { 11 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 12 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 13 }, 14 + identityResolver: new LocalActorResolver({ 15 handleResolver: handleResolver, 16 + didDocumentResolver: reactiveDidDocumentResolver, 17 }), 18 });
+22 -14
src/utils/api.ts
··· 16 import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver"; 17 import { Did, Handle } from "@atcute/lexicons"; 18 import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; 19 import { createStore } from "solid-js/store"; 20 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", 26 }), 27 - web: new AtprotoWebDidDocumentResolver(), 28 - }, 29 - }); 30 31 export const handleResolver = new CompositeHandleResolver({ 32 strategy: "dns-first", ··· 40 dohUrl: "https://dns.google/resolve?", 41 }); 42 43 - const schemaResolver = new LexiconSchemaResolver({ 44 - didDocumentResolver: didDocumentResolver, 45 - }); 46 47 const didPDSCache: Record<string, string> = {}; 48 const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({}); ··· 54 throw new Error("Not a valid DID identifier"); 55 } 56 57 - const doc = await didDocumentResolver.resolve(did); 58 didDocCache[did] = doc; 59 60 const pds = getPdsEndpoint(doc); ··· 83 if (!isAtprotoDid(did)) { 84 throw new Error("Not a valid DID identifier"); 85 } 86 - return await didDocumentResolver.resolve(did); 87 }; 88 89 const validateHandle = async (handle: Handle, did: Did) => { ··· 145 }; 146 147 const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { 148 - return await schemaResolver.resolve(authority, nsid); 149 }; 150 151 interface LinkData {
··· 16 import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver"; 17 import { Did, Handle } from "@atcute/lexicons"; 18 import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; 19 + import { createMemo } from "solid-js"; 20 import { createStore } from "solid-js/store"; 21 import { setPDS } from "../components/navbar"; 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 + }, 33 }), 34 + ); 35 36 export const handleResolver = new CompositeHandleResolver({ 37 strategy: "dns-first", ··· 45 dohUrl: "https://dns.google/resolve?", 46 }); 47 48 + const schemaResolver = createMemo( 49 + () => 50 + new LexiconSchemaResolver({ 51 + didDocumentResolver: didDocumentResolver(), 52 + }), 53 + ); 54 55 const didPDSCache: Record<string, string> = {}; 56 const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({}); ··· 62 throw new Error("Not a valid DID identifier"); 63 } 64 65 + const doc = await didDocumentResolver().resolve(did); 66 didDocCache[did] = doc; 67 68 const pds = getPdsEndpoint(doc); ··· 91 if (!isAtprotoDid(did)) { 92 throw new Error("Not a valid DID identifier"); 93 } 94 + return await didDocumentResolver().resolve(did); 95 }; 96 97 const validateHandle = async (handle: Handle, did: Did) => { ··· 153 }; 154 155 const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { 156 + return await schemaResolver().resolve(authority, nsid); 157 }; 158 159 interface LinkData {
+1 -1
LICENSE
··· 1 - Copyright (c) 2024-2025 Juliet Philippe <m@juli.ee> 2 3 Permission to use, copy, modify, and/or distribute this software for any 4 purpose with or without fee is hereby granted.
··· 1 + Copyright (c) 2024-2026 Juliet Philippe <m@juli.ee> 2 3 Permission to use, copy, modify, and/or distribute this software for any 4 purpose with or without fee is hereby granted.
+2 -1
README.md
··· 1 - # PDSls - AT Protocol Explorer 2 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 ··· 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 - Query moderation labels. 12 13 ## Hacking 14
··· 1 + # PDSls - Atmosphere Explorer 2 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 ··· 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 - Query moderation labels. 12 + - Explore and unpack repository archives (CAR). 13 14 ## Hacking 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;
-1
src/views/car/explore.tsx
··· 544 </Show> 545 </button> 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 > 549 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 550 </HoverCard>
··· 544 </Show> 545 </button> 546 } 547 > 548 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 549 </HoverCard>
+126 -2
src/auth/account.tsx
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 - import { createSignal, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 17 retrieveSession, 18 saveSessionToStorage, 19 } from "./session-manager.js"; 20 - import { agent, openManager, sessions, setAgent, setOpenManager, setSessions } from "./state.js"; 21 22 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 const removeSession = async (did: Did) => { ··· 73 beforeRedirect: (account) => resumeSession(account as Did), 74 }); 75 76 const handleAccountClick = async (did: Did) => { 77 try { 78 await resumeSession(did);
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 + import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 17 retrieveSession, 18 saveSessionToStorage, 19 } from "./session-manager.js"; 20 + import { 21 + agent, 22 + openManager, 23 + pendingPermissionEdit, 24 + sessions, 25 + setAgent, 26 + setOpenManager, 27 + setPendingPermissionEdit, 28 + setSessions, 29 + } from "./state.js"; 30 31 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 32 const removeSession = async (did: Did) => { ··· 82 beforeRedirect: (account) => resumeSession(account as Did), 83 }); 84 85 + createEffect(() => { 86 + const pending = pendingPermissionEdit(); 87 + if (pending) { 88 + scopeFlow.initiateWithRedirect(pending); 89 + setPendingPermissionEdit(null); 90 + } 91 + }); 92 + 93 const handleAccountClick = async (did: Did) => { 94 try { 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 onClose={() => setOpenDialog(false)} 236 closeOnClick={false} 237 nonBlocking={isMinimized()} 238 > 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(),
··· 235 onClose={() => setOpenDialog(false)} 236 closeOnClick={false} 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" : ""}`} 240 > 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 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 } 9 10 export const Modal = (props: ModalProps) => { ··· 12 <Show when={props.open}> 13 <div 14 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" 16 classList={{ 17 "pointer-events-none": props.nonBlocking, 18 }} 19 ref={(node) => { 20 const handleEscape = (e: KeyboardEvent) => {
··· 5 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 + alignTop?: boolean; 9 + contentClass?: string; 10 } 11 12 export const Modal = (props: ModalProps) => { ··· 14 <Show when={props.open}> 15 <div 16 data-modal 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" 18 classList={{ 19 "pointer-events-none": props.nonBlocking, 20 + "items-start pt-18": props.alignTop, 21 + "items-center": !props.alignTop, 22 }} 23 ref={(node) => { 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 416 417 418 - 419 <span class="iconify lucide--trash-2"></span> 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>
··· 415 416 417 418 + > 419 <span class="iconify lucide--trash-2"></span> 420 </PermissionButton> 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>
+363 -315
src/views/stream/index.tsx
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 import { JSONValue } from "../../components/json"; 7 - import { StickyOverlay } from "../../components/sticky"; 8 import { TextInput } from "../../components/text-input"; 9 import { StreamStats, StreamStatsPanel } from "./stats"; 10 11 const LIMIT = 20; 12 - type Parameter = { name: string; param: string | string[] | undefined }; 13 14 - const StreamView = () => { 15 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>>([]); 19 const [connected, setConnected] = createSignal(false); 20 const [paused, setPaused] = createSignal(false); 21 const [notice, setNotice] = createSignal(""); 22 23 24 25 26 27 28 29 30 ··· 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 ··· 67 68 69 70 71 72 73 74 75 ··· 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 return ( 267 <> 268 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 269 - <div class="flex w-full flex-col items-center"> 270 <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 - 283 284 - </A> 285 </div> 286 <Show when={!connected()}> 287 - <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 288 <label class="flex items-center justify-end gap-x-1"> 289 - <span class="min-w-20">Instance</span> 290 <TextInput 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 - 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 - 350 </form> 351 </Show> 352 <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> 395 </div> 396 - </StickyOverlay> 397 </Show> 398 <Show when={notice().length}> 399 <div class="text-red-500 dark:text-red-400">{notice()}</div> 400 </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> 410 </div> 411 </> 412 );
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 + import DidHoverCard from "../../components/hover-card/did"; 7 import { JSONValue } from "../../components/json"; 8 import { TextInput } from "../../components/text-input"; 9 + import { addToClipboard } from "../../utils/copy"; 10 + import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 11 import { StreamStats, StreamStatsPanel } from "./stats"; 12 13 const LIMIT = 20; 14 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="shrink-0 text-neutral-400 dark:text-neutral-500">{info().rkey}</span> 66 + </Show> 67 + </div> 68 + <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"> 69 + <Show when={info().did}> 70 + <span class="w-fit" onclick={(e) => e.stopPropagation()}> 71 + <DidHoverCard newTab did={info().did!} /> 72 + </span> 73 + </Show> 74 + <Show when={info().time}> 75 + <span>{info().time}</span> 76 + </Show> 77 + </div> 78 + </div> 79 + </button> 80 + <Show when={expanded()}> 81 + <button 82 + type="button" 83 + onclick={copyRecord} 84 + 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" 85 + > 86 + <span class="iconify lucide--copy"></span> 87 + </button> 88 + </Show> 89 + </div> 90 + <Show when={expanded()}> 91 + <div class="ml-6.5"> 92 + <div class="w-full text-xs wrap-anywhere whitespace-pre-wrap md:w-2xl"> 93 + <JSONValue newTab data={props.record} repo={info().did ?? ""} hideBlobs /> 94 + </div> 95 + </div> 96 + </Show> 97 + </div> 98 + ); 99 + }; 100 + 101 + export const StreamView = () => { 102 const [searchParams, setSearchParams] = useSearchParams(); 103 + const streamType = getStreamType(useLocation().pathname); 104 + const config = () => STREAM_CONFIGS[streamType]; 105 + 106 + const [records, setRecords] = createSignal<any[]>([]); 107 const [connected, setConnected] = createSignal(false); 108 const [paused, setPaused] = createSignal(false); 109 const [notice, setNotice] = createSignal(""); 110 + const [parameters, setParameters] = createSignal<{ name: string; value?: string }[]>([]); 111 + const [stats, setStats] = createSignal<StreamStats>({ 112 + totalEvents: 0, 113 + eventsPerSecond: 0, 114 115 + collections: {}, 116 + }); 117 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 118 119 + let socket: WebSocket; 120 + let firehose: Firehose; 121 + let formRef!: HTMLFormElement; 122 123 + let rafId: number | null = null; 124 + let statsIntervalId: number | null = null; 125 + let statsUpdateIntervalId: number | null = null; 126 + let currentSecondEventCount = 0; 127 + let totalEventsCount = 0; 128 + let eventTypesMap: Record<string, number> = {}; 129 + let collectionsMap: Record<string, number> = {}; 130 131 + const addRecord = (record: any) => { 132 + currentSecondEventCount++; 133 + totalEventsCount++; 134 135 + const rawEventType = record.kind || record.$type || "unknown"; 136 + const eventType = rawEventType.includes("#") ? rawEventType.split("#").pop() : rawEventType; 137 + eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 138 139 + if (eventType !== "account" && eventType !== "identity") { 140 + const collection = 141 + record.commit?.collection || 142 + record.op?.path?.split("/")[0] || 143 + record.link?.source || 144 + "unknown"; 145 + collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 146 + } 147 148 149 ··· 155 156 157 158 + }; 159 160 + const disconnect = () => { 161 + if (!config().useFirehoseLib) socket?.close(); 162 + else firehose?.close(); 163 164 + if (rafId !== null) { 165 + cancelAnimationFrame(rafId); 166 + rafId = null; 167 168 169 170 171 172 173 + clearInterval(statsUpdateIntervalId); 174 + statsUpdateIntervalId = null; 175 + } 176 177 + pendingRecords = []; 178 + totalEventsCount = 0; 179 + eventTypesMap = {}; 180 + collectionsMap = {}; 181 + setConnected(false); 182 + setPaused(false); 183 + setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 184 + }; 185 186 + const connectStream = async (formData: FormData) => { 187 + setNotice(""); 188 + if (connected()) { 189 + disconnect(); 190 191 + } 192 + setRecords([]); 193 194 + const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 195 + const url = config().buildUrl(instance, formData); 196 197 + // Save all form fields to URL params 198 + const params: Record<string, string | undefined> = { instance }; 199 + config().fields.forEach((field) => { 200 + params[field.searchParam] = formData.get(field.name)?.toString(); 201 + }); 202 + setSearchParams(params); 203 204 + // Build parameters display 205 + setParameters([ 206 + { name: "Instance", value: instance }, 207 + ...config() 208 + .fields.filter((f) => f.type !== "checkbox") 209 + .map((f) => ({ name: f.label, value: formData.get(f.name)?.toString() })), 210 + ...config() 211 + .fields.filter((f) => f.type === "checkbox" && formData.get(f.name) === "on") 212 + .map((f) => ({ name: f.label, value: "on" })), 213 + ]); 214 215 + setConnected(true); 216 + const now = Date.now(); 217 + setCurrentTime(now); 218 219 + totalEventsCount = 0; 220 + eventTypesMap = {}; 221 + collectionsMap = {}; 222 223 224 ··· 234 235 236 237 + })); 238 + }, 50); 239 240 + statsIntervalId = window.setInterval(() => { 241 + setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 242 + currentSecondEventCount = 0; 243 + setCurrentTime(Date.now()); 244 + }, 1000); 245 246 + if (!config().useFirehoseLib) { 247 + socket = new WebSocket(url); 248 + socket.addEventListener("message", (event) => { 249 + const rec = JSON.parse(event.data); 250 + const isFilteredEvent = rec.kind === "account" || rec.kind === "identity"; 251 + if (!isFilteredEvent || streamType !== "jetstream" || searchParams.allEvents === "on") 252 + addRecord(rec); 253 + }); 254 + socket.addEventListener("error", () => { 255 256 + disconnect(); 257 + }); 258 + } else { 259 + const cursor = formData.get("cursor")?.toString(); 260 + firehose = new Firehose({ 261 + relay: url, 262 + cursor: cursor, 263 264 265 ··· 267 268 269 270 + }); 271 + firehose.on("commit", (commit) => { 272 + for (const op of commit.ops) { 273 + addRecord({ 274 + $type: commit.$type, 275 + repo: commit.repo, 276 + seq: commit.seq, 277 278 + rev: commit.rev, 279 + since: commit.since, 280 + op: op, 281 + }); 282 + } 283 + }); 284 + firehose.on("identity", (identity) => addRecord(identity)); 285 + firehose.on("account", (account) => addRecord(account)); 286 + firehose.on("sync", (sync) => { 287 + addRecord({ 288 + $type: sync.$type, 289 + did: sync.did, 290 + rev: sync.rev, 291 + seq: sync.seq, 292 + time: sync.time, 293 + }); 294 + }); 295 + firehose.start(); 296 + } 297 + }; 298 299 + onMount(() => { 300 + if (searchParams.instance) { 301 + const formData = new FormData(); 302 + formData.append("instance", searchParams.instance.toString()); 303 + config().fields.forEach((field) => { 304 + const value = searchParams[field.searchParam]; 305 + if (value) formData.append(field.name, value.toString()); 306 + }); 307 + connectStream(formData); 308 + } 309 + }); 310 311 + onCleanup(() => { 312 + socket?.close(); 313 + firehose?.close(); 314 + if (rafId !== null) cancelAnimationFrame(rafId); 315 + if (statsIntervalId !== null) clearInterval(statsIntervalId); 316 + if (statsUpdateIntervalId !== null) clearInterval(statsUpdateIntervalId); 317 + }); 318 319 return ( 320 <> 321 + <Title>{config().label} - PDSls</Title> 322 + <div class="flex w-full flex-col items-center gap-2"> 323 + {/* Tab Navigation */} 324 <div class="flex gap-4 font-medium"> 325 + <For each={STREAM_TYPES}> 326 + {(type) => ( 327 + <A 328 + class="flex items-center gap-1 border-b-2" 329 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 330 + href={`/${type}`} 331 + > 332 + {STREAM_CONFIGS[type].label} 333 + </A> 334 + )} 335 + </For> 336 + </div> 337 338 + {/* Stream Description */} 339 + <div class="w-full px-2 text-center"> 340 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{config().description}</p> 341 </div> 342 + 343 + {/* Connection Form */} 344 <Show when={!connected()}> 345 + <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 346 <label class="flex items-center justify-end gap-x-1"> 347 + <span class="min-w-21 select-none">Instance</span> 348 <TextInput 349 + name="instance" 350 + value={searchParams.instance ?? config().defaultInstance} 351 + class="grow" 352 + /> 353 + </label> 354 + 355 + <For each={config().fields}> 356 + {(field) => ( 357 + <label class="flex items-center justify-end gap-x-1"> 358 + <Show when={field.type === "checkbox"}> 359 + <input 360 + type="checkbox" 361 + name={field.name} 362 + id={field.name} 363 + checked={searchParams[field.searchParam] === "on"} 364 + /> 365 + </Show> 366 + <span class="min-w-21 select-none">{field.label}</span> 367 + <Show when={field.type === "textarea"}> 368 + <textarea 369 + name={field.name} 370 + spellcheck={false} 371 + placeholder={field.placeholder} 372 + value={(searchParams[field.searchParam] as string) ?? ""} 373 + 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" 374 + /> 375 + </Show> 376 + <Show when={field.type === "text"}> 377 + <TextInput 378 + name={field.name} 379 + placeholder={field.placeholder} 380 + value={(searchParams[field.searchParam] as string) ?? ""} 381 + class="grow" 382 + /> 383 + </Show> 384 + </label> 385 + )} 386 + </For> 387 388 + <div class="flex justify-end gap-2"> 389 + <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 390 + </div> 391 </form> 392 </Show> 393 + 394 + {/* Connected State */} 395 <Show when={connected()}> 396 + <div class="flex w-full flex-col gap-2 p-2"> 397 + <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 398 + <div class="font-semibold">Parameters</div> 399 + <For each={parameters()}> 400 + {(param) => ( 401 + <Show when={param.value}> 402 + <div class="text-sm"> 403 + <div class="text-xs text-neutral-500 dark:text-neutral-400">{param.name}</div> 404 + <div class="text-neutral-700 dark:text-neutral-300">{param.value}</div> 405 + </div> 406 + </Show> 407 + )} 408 + </For> 409 + </div> 410 + <StreamStatsPanel 411 + stats={stats()} 412 + currentTime={currentTime()} 413 + streamType={streamType} 414 + showAllEvents={searchParams.allEvents === "on"} 415 + /> 416 + <div class="flex justify-end gap-2"> 417 + <Button 418 + ontouchstart={(e) => { 419 + e.preventDefault(); 420 + requestAnimationFrame(() => setPaused(!paused())); 421 + }} 422 + onClick={() => setPaused(!paused())} 423 + > 424 + {paused() ? "Resume" : "Pause"} 425 + </Button> 426 + <Button 427 + ontouchstart={(e) => { 428 + e.preventDefault(); 429 + requestAnimationFrame(() => disconnect()); 430 + }} 431 + onClick={disconnect} 432 + > 433 + Disconnect 434 + </Button> 435 </div> 436 + </div> 437 </Show> 438 + 439 + {/* Error Notice */} 440 <Show when={notice().length}> 441 <div class="text-red-500 dark:text-red-400">{notice()}</div> 442 </Show> 443 + 444 + {/* Records List */} 445 + <Show when={connected() || records().length > 0}> 446 + <div class="flex min-h-280 w-full flex-col gap-2 font-mono text-xs [overflow-anchor:auto] sm:text-sm"> 447 + <For each={records().toReversed()}> 448 + {(rec) => ( 449 + <div class="[overflow-anchor:none]"> 450 + <StreamRecordItem record={rec} streamType={streamType} /> 451 + </div> 452 + )} 453 + </For> 454 + <div class="h-px [overflow-anchor:auto]" /> 455 + </div> 456 + </Show> 457 </div> 458 </> 459 ); 460 + };
+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 import { NavBar } from "./components/navbar.jsx"; 10 import { NotificationContainer } from "./components/notification.jsx"; 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 - import { Search, SearchButton, showSearch } from "./components/search.jsx"; 13 import { themeEvent } from "./components/theme.jsx"; 14 import { resolveHandle } from "./utils/api.js"; 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 </Show> 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 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%)]" : ""}`} 130 style={{ 131 "background-image": 132 props.params.repo && props.params.repo in headers ? ··· 149 /> 150 </Show> 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 /> 154 <Show when={agent()}> 155 <RecordEditor create={true} scope="create" /> 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 164 165 ··· 170 </div> 171 </header> 172 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 173 - <Show when={showSearch() || location.pathname === "/"}> 174 - <Search /> 175 - </Show> 176 <Show when={props.params.pds}> 177 <NavBar params={props.params} /> 178 </Show>
··· 9 import { NavBar } from "./components/navbar.jsx"; 10 import { NotificationContainer } from "./components/notification.jsx"; 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 + import { Search, SearchButton } from "./components/search.jsx"; 13 import { themeEvent } from "./components/theme.jsx"; 14 import { resolveHandle } from "./utils/api.js"; 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 </Show> 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 128 <header 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 style={{ 131 "background-image": 132 props.params.repo && props.params.repo in headers ? ··· 149 /> 150 </Show> 151 </A> 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> 156 <Show when={agent()}> 157 <RecordEditor create={true} scope="create" /> 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" /> 165 166 167 ··· 172 </div> 173 </header> 174 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 175 + <Search /> 176 <Show when={props.params.pds}> 177 <NavBar params={props.params} /> 178 </Show>
+396 -319
pnpm-lock.yaml
··· 35 36 37 38 39 40 ··· 85 86 87 88 89 90 91 92 93 94 95 96 97 ··· 128 129 130 131 132 133 134 ··· 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 ··· 813 814 815 816 817 818 819 820 ··· 844 845 846 847 848 849 850 851 ··· 905 906 907 908 909 910 911 912 ··· 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 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==} 1410 engines: {node: '>=10'} 1411 peerDependencies: 1412 seroval: ^1.0 1413 1414 - seroval@1.3.2: 1415 - resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} 1416 engines: {node: '>=10'} 1417 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 - 1530 1531 1532 1533 ··· 1729 1730 1731 1732 1733 1734 1735 ··· 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 ··· 2315 2316 2317 2318 2319 2320 2321 2322 ··· 2340 2341 2342 2343 2344 2345 2346 ··· 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 ··· 2421 2422 2423 2424 2425 2426 2427 2428 ··· 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 ··· 2701 2702 2703 2704 - semver@6.3.1: {} 2705 2706 - seroval-plugins@1.3.3(seroval@1.3.2): 2707 dependencies: 2708 - seroval: 1.3.2 2709 - 2710 - seroval@1.3.2: {} 2711 2712 - solid-js@1.9.10: 2713 dependencies: 2714 - csstype: 3.2.3 2715 - seroval: 1.3.2 2716 - seroval-plugins: 1.3.3(seroval@1.3.2) 2717 2718 - solid-refresh@0.6.3(solid-js@1.9.10): 2719 - dependencies:
··· 35 36 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 44 45 46 ··· 91 92 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 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 113 114 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 132 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)) 141 142 + packages: 143 144 145 ··· 176 177 178 179 + '@atcute/identity@1.1.3': 180 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 181 182 + '@atcute/lexicon-doc@2.0.6': 183 + resolution: {integrity: sha512-iDYJkuom+tIw3zIvU1ggCEVFfReXKfOUtIhpY2kEg2kQeSfMB75F+8k1QOpeAQBetyWYmjsHqBuSUX9oQS6L1Q==} 184 185 186 ··· 718 719 720 721 + '@noble/secp256k1@3.0.0': 722 + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 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] 728 729 + '@rollup/rollup-android-arm64@4.56.0': 730 + resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} 731 + cpu: [arm64] 732 + os: [android] 733 734 + '@rollup/rollup-darwin-arm64@4.56.0': 735 + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} 736 + cpu: [arm64] 737 + os: [darwin] 738 739 + '@rollup/rollup-darwin-x64@4.56.0': 740 + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} 741 + cpu: [x64] 742 + os: [darwin] 743 744 + '@rollup/rollup-freebsd-arm64@4.56.0': 745 + resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} 746 + cpu: [arm64] 747 + os: [freebsd] 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] 753 754 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 755 + resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} 756 + cpu: [arm] 757 + os: [linux] 758 759 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 760 + resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} 761 + cpu: [arm] 762 + os: [linux] 763 764 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 765 + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} 766 + cpu: [arm64] 767 + os: [linux] 768 769 + '@rollup/rollup-linux-arm64-musl@4.56.0': 770 + resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} 771 + cpu: [arm64] 772 + os: [linux] 773 774 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 775 + resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} 776 + cpu: [loong64] 777 + os: [linux] 778 779 + '@rollup/rollup-linux-loong64-musl@4.56.0': 780 + resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} 781 + cpu: [loong64] 782 + os: [linux] 783 784 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 785 + resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} 786 + cpu: [ppc64] 787 + os: [linux] 788 789 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 790 + resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} 791 + cpu: [ppc64] 792 + os: [linux] 793 794 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 795 + resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} 796 + cpu: [riscv64] 797 + os: [linux] 798 799 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 800 + resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} 801 + cpu: [riscv64] 802 + os: [linux] 803 804 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 805 + resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} 806 + cpu: [s390x] 807 + os: [linux] 808 809 + '@rollup/rollup-linux-x64-gnu@4.56.0': 810 + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} 811 + cpu: [x64] 812 + os: [linux] 813 814 + '@rollup/rollup-linux-x64-musl@4.56.0': 815 + resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} 816 + cpu: [x64] 817 + os: [linux] 818 819 + '@rollup/rollup-openbsd-x64@4.56.0': 820 + resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} 821 + cpu: [x64] 822 + os: [openbsd] 823 824 + '@rollup/rollup-openharmony-arm64@4.56.0': 825 + resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} 826 + cpu: [arm64] 827 + os: [openharmony] 828 829 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 830 + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} 831 + cpu: [arm64] 832 + os: [win32] 833 834 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 835 + resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} 836 + cpu: [ia32] 837 + os: [win32] 838 839 + '@rollup/rollup-win32-x64-gnu@4.56.0': 840 + resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} 841 + cpu: [x64] 842 + os: [win32] 843 844 + '@rollup/rollup-win32-x64-msvc@4.56.0': 845 + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} 846 + cpu: [x64] 847 + os: [win32] 848 849 850 ··· 967 968 969 970 + '@types/estree@1.0.8': 971 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 972 973 + '@types/node@25.0.10': 974 + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} 975 976 + acorn@8.15.0: 977 + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 978 979 980 ··· 1004 1005 1006 1007 + bun-types@1.3.6: 1008 + resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==} 1009 1010 + caniuse-lite@1.0.30001766: 1011 + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} 1012 1013 + codemirror@6.0.2: 1014 + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} 1015 1016 1017 ··· 1071 1072 1073 1074 + domutils@3.2.2: 1075 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 1076 1077 + electron-to-chromium@1.5.278: 1078 + resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} 1079 1080 + enhanced-resolve@5.18.4: 1081 + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} 1082 1083 1084 ··· 1375 1376 1377 1378 + prettier-plugin-svelte: 1379 + optional: true 1380 1381 + prettier@3.8.1: 1382 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 1383 + engines: {node: '>=14'} 1384 + hasBin: true 1385 1386 + resolve-pkg-maps@1.0.0: 1387 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 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 1393 1394 1395 1396 1397 1398 1399 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 1400 + hasBin: true 1401 1402 + seroval-plugins@1.5.0: 1403 + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} 1404 engines: {node: '>=10'} 1405 peerDependencies: 1406 seroval: ^1.0 1407 1408 + seroval@1.5.0: 1409 + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} 1410 engines: {node: '>=10'} 1411 1412 + solid-js@1.9.11: 1413 + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} 1414 1415 + solid-refresh@0.6.3: 1416 + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} 1417 1418 1419 ··· 1615 1616 1617 1618 + '@atcute/lexicons': 1.2.6 1619 + '@badrap/valita': 0.4.6 1620 1621 + '@atcute/lexicon-doc@2.0.6': 1622 + dependencies: 1623 + '@atcute/identity': 1.1.3 1624 1625 1626 ··· 2116 2117 2118 2119 + '@noble/secp256k1@3.0.0': {} 2120 2121 + '@rollup/rollup-android-arm-eabi@4.56.0': 2122 + optional: true 2123 2124 + '@rollup/rollup-android-arm64@4.56.0': 2125 + optional: true 2126 2127 + '@rollup/rollup-darwin-arm64@4.56.0': 2128 + optional: true 2129 2130 + '@rollup/rollup-darwin-x64@4.56.0': 2131 + optional: true 2132 2133 + '@rollup/rollup-freebsd-arm64@4.56.0': 2134 + optional: true 2135 2136 + '@rollup/rollup-freebsd-x64@4.56.0': 2137 + optional: true 2138 2139 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 2140 + optional: true 2141 2142 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 2143 + optional: true 2144 2145 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 2146 + optional: true 2147 2148 + '@rollup/rollup-linux-arm64-musl@4.56.0': 2149 + optional: true 2150 2151 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 2152 + optional: true 2153 2154 + '@rollup/rollup-linux-loong64-musl@4.56.0': 2155 + optional: true 2156 2157 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 2158 + optional: true 2159 2160 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 2161 + optional: true 2162 2163 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 2164 + optional: true 2165 2166 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 2167 + optional: true 2168 2169 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 2170 + optional: true 2171 2172 + '@rollup/rollup-linux-x64-gnu@4.56.0': 2173 + optional: true 2174 2175 + '@rollup/rollup-linux-x64-musl@4.56.0': 2176 + optional: true 2177 2178 + '@rollup/rollup-openbsd-x64@4.56.0': 2179 + optional: true 2180 2181 + '@rollup/rollup-openharmony-arm64@4.56.0': 2182 + optional: true 2183 2184 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 2185 + optional: true 2186 2187 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 2188 + optional: true 2189 2190 + '@rollup/rollup-win32-x64-gnu@4.56.0': 2191 + optional: true 2192 2193 + '@rollup/rollup-win32-x64-msvc@4.56.0': 2194 + optional: true 2195 2196 + '@skyware/firehose@0.5.2': 2197 2198 2199 + '@atcute/cbor': 2.3.0 2200 + nanoevents: 9.1.0 2201 2202 + '@solidjs/meta@0.29.4(solid-js@1.9.11)': 2203 + dependencies: 2204 + solid-js: 1.9.11 2205 2206 + '@solidjs/router@0.15.4(solid-js@1.9.11)': 2207 + dependencies: 2208 + solid-js: 1.9.11 2209 2210 + '@standard-schema/spec@1.1.0': {} 2211 2212 2213 ··· 2267 2268 2269 2270 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 2271 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 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) 2279 2280 + '@types/babel__core@7.20.5': 2281 + dependencies: 2282 2283 2284 ··· 2302 2303 2304 2305 + '@types/estree@1.0.8': {} 2306 2307 + '@types/node@25.0.10': 2308 + dependencies: 2309 + undici-types: 7.16.0 2310 2311 2312 ··· 2316 2317 2318 2319 + html-entities: 2.3.3 2320 + parse5: 7.3.0 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 2328 2329 + baseline-browser-mapping@2.9.17: {} 2330 2331 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) 2340 2341 + bun-types@1.3.6: 2342 + dependencies: 2343 + '@types/node': 25.0.10 2344 2345 + caniuse-lite@1.0.30001766: {} 2346 2347 + codemirror@6.0.2: 2348 + dependencies: 2349 2350 2351 ··· 2409 2410 2411 2412 + domelementtype: 2.3.0 2413 + domhandler: 5.0.3 2414 2415 + electron-to-chromium@1.5.278: {} 2416 2417 + enhanced-resolve@5.18.4: 2418 + dependencies: 2419 2420 2421 ··· 2638 2639 2640 2641 + picocolors: 1.1.1 2642 + source-map-js: 1.2.1 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 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) 2654 2655 + prettier@3.8.1: {} 2656 2657 + resolve-pkg-maps@1.0.0: 2658 + optional: true 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 2690 2691 + sax@1.4.4: {} 2692 2693 + semver@6.3.1: {} 2694 2695 + seroval-plugins@1.5.0(seroval@1.5.0): 2696 + dependencies: 2697 + seroval: 1.5.0 2698 2699 + seroval@1.5.0: {} 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) 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 2715 2716 2717 ··· 2757 2758 2759 2760 + escalade: 3.2.0 2761 + picocolors: 1.1.1 2762 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)): 2764 dependencies: 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 2775 2776 + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2): 2777 dependencies: 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: {} 2796
+338 -1
src/views/collection.tsx
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 - <span class="shrink-0 text-sm text-blue-500 dark:text-blue-400">{props.record.rkey}</span> 44 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 45 {props.record.cid} 46 </span>
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 44 + {props.record.rkey} 45 + </span> 46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 47 {props.record.cid} 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 }; 45 46 return ( 47 - <div class="flex flex-col gap-y-2"> 48 - <div class="mb-1 flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 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 </div> 78 <button 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" 81 > 82 Continue 83 </button>
··· 44 }; 45 46 return ( 47 + <div class="flex flex-col gap-y-3"> 48 + <div class="flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 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 </div> 78 <button 79 onclick={handleConfirm} 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 > 82 Continue 83 </button>
-14
src/utils/templates.ts
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 - "blue.linkat.board": (uri) => ({ 41 - label: "Linkat", 42 - link: `https://linkat.blue/${uri.repo}`, 43 - }), 44 "sh.tangled.actor.profile": (uri) => ({ 45 label: "Tangled", 46 link: `https://tangled.org/${uri.repo}`, ··· 51 link: `https://tangled.org/${uri.repo}/${record.name}`, 52 icon: "i-tangled", 53 }), 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 };
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 "sh.tangled.actor.profile": (uri) => ({ 41 label: "Tangled", 42 link: `https://tangled.org/${uri.repo}`, ··· 47 link: `https://tangled.org/${uri.repo}/${record.name}`, 48 icon: "i-tangled", 49 }), 50 };
-12
src/utils/types/lexicons.ts
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 - import { 21 - PubLeafletComment, 22 - PubLeafletDocument, 23 - PubLeafletGraphSubscription, 24 - PubLeafletPublication, 25 - } from "@atcute/leaflet"; 26 import { 27 ShTangledActorProfile, 28 ShTangledFeedStar, ··· 85 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 86 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 87 "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 };
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 import { 21 ShTangledActorProfile, 22 ShTangledFeedStar, ··· 79 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 80 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 81 "sh.tangled.knot": ShTangledKnot.mainSchema, 82 };
+1
.gitignore
··· 2 dist 3 .env 4 .DS_Store
··· 2 dist 3 .env 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 "build": "vite build", 11 "serve": "vite preview" 12 }, 13 - "pnpm": { 14 - "overrides": { 15 - "seroval": "^1.4.1" 16 - } 17 - }, 18 "devDependencies": { 19 "@iconify-json/lucide": "^1.2.86", 20 "@iconify/tailwind4": "^1.2.1", 21 "@tailwindcss/vite": "^4.1.18", 22 - "prettier": "^3.8.0", 23 "prettier-plugin-organize-imports": "^4.3.0", 24 "prettier-plugin-tailwindcss": "^0.7.2", 25 "tailwindcss": "^4.1.18", ··· 60 "@solidjs/router": "^0.15.4", 61 "codemirror": "^6.0.2", 62 "native-file-system-adapter": "^3.0.1", 63 - "solid-js": "^1.9.10" 64 }, 65 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 66 }
··· 10 "build": "vite build", 11 "serve": "vite preview" 12 }, 13 "devDependencies": { 14 "@iconify-json/lucide": "^1.2.86", 15 "@iconify/tailwind4": "^1.2.1", 16 "@tailwindcss/vite": "^4.1.18", 17 + "prettier": "^3.8.1", 18 "prettier-plugin-organize-imports": "^4.3.0", 19 "prettier-plugin-tailwindcss": "^0.7.2", 20 "tailwindcss": "^4.1.18", ··· 55 "@solidjs/router": "^0.15.4", 56 "codemirror": "^6.0.2", 57 "native-file-system-adapter": "^3.0.1", 58 + "solid-js": "^1.9.11" 59 }, 60 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 61 }
+3 -1
src/components/button.tsx
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 children?: JSX.Element; 10 } 11 ··· 16 disabled={props.disabled ?? false} 17 class={ 18 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 } 21 classList={props.classList} 22 onClick={props.onClick} 23 > 24 {props.children} 25 </button>
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 + ontouchstart?: (e: TouchEvent) => void; 10 children?: JSX.Element; 11 } 12 ··· 17 disabled={props.disabled ?? false} 18 class={ 19 props.class ?? 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" 21 } 22 classList={props.classList} 23 onClick={props.onClick} 24 + ontouchstart={props.ontouchstart} 25 > 26 {props.children} 27 </button>
+4 -2
src/views/labels.tsx
··· 277 <Button 278 onClick={handleLoadMore} 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" 281 > 282 <Show 283 when={!loading()} 284 - fallback={<span class="iconify lucide--loader-circle animate-spin" />} 285 > 286 Load more 287 </Show>
··· 277 <Button 278 onClick={handleLoadMore} 279 disabled={loading()} 280 + classList={{ "w-20 justify-center": true }} 281 > 282 <Show 283 when={!loading()} 284 + fallback={ 285 + <span class="iconify lucide--loader-circle animate-spin text-base" /> 286 + } 287 > 288 Load more 289 </Show>
+13 -5
src/views/pds.tsx
··· 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 <div class="flex flex-col items-center gap-1 pb-2"> 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> 261 </Show> 262 </div> 263 </div>
··· 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 <div class="flex flex-col items-center gap-1 pb-2"> 255 <p>{repos()?.length} loaded</p> 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> 269 </Show> 270 </div> 271 </div>