forked from pds.ls/pdsls
this repo has no description

Compare changes

Choose any two refs to compare.

+77 -1821
+4 -1
src/components/button.tsx
··· 5 5 classList?: Record<string, boolean | undefined>; 6 6 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 7 7 children?: JSX.Element; 8 + type?: "button" | "submit" | "reset"; 9 + disabled?: boolean; 8 10 } 9 11 10 12 export const Button = (props: ButtonProps) => { 11 13 return ( 12 14 <button 13 - type="button" 15 + type={props.type ?? "button"} 16 + disabled={props.disabled} 14 17 class={ 15 18 props.class ?? 16 19 "dark:hover:bg-dark-200 dark:shadow-dark-800 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 font-semibold shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
+2 -14
src/index.tsx
··· 3 3 import { render } from "solid-js/web"; 4 4 import { Layout } from "./layout.tsx"; 5 5 import "./styles/index.css"; 6 - import { CollectionView } from "./views/collection.tsx"; 7 6 import { Home } from "./views/home.tsx"; 8 - import { LabelView } from "./views/labels.tsx"; 9 - import { PdsView } from "./views/pds.tsx"; 10 - import { RecordView } from "./views/record.tsx"; 11 - import { RepoView } from "./views/repo.tsx"; 12 - import { Settings } from "./views/settings.tsx"; 13 - import { StreamView } from "./views/stream.tsx"; 7 + import { EnrollView } from "./views/enroll.tsx"; 14 8 15 9 render( 16 10 () => ( 17 11 <Router root={Layout}> 18 12 <Route path="/" component={Home} /> 19 - <Route path={["/jetstream", "/firehose"]} component={StreamView} /> 20 - <Route path="/settings" component={Settings} /> 21 - <Route path="/:pds" component={PdsView} /> 22 - <Route path="/:pds/:repo" component={RepoView} /> 23 - <Route path="/:pds/:repo/labels" component={LabelView} /> 24 - <Route path="/:pds/:repo/:collection" component={CollectionView} /> 25 - <Route path="/:pds/:repo/:collection/:rkey" component={RecordView} /> 13 + <Route path="/:pds/:repo" component={EnrollView} /> 26 14 </Router> 27 15 ), 28 16 document.getElementById("root") as HTMLElement,
-13
src/layout.tsx
··· 68 68 <Show when={agent()}> 69 69 <RecordEditor create={true} /> 70 70 </Show> 71 - <AccountManager /> 72 - <MenuProvider> 73 - <DropdownMenu 74 - icon="lucide--menu text-xl" 75 - buttonClass="rounded-lg p-1" 76 - menuClass="top-8 p-3 text-sm" 77 - > 78 - <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 79 - <NavMenu href="/firehose" label="Firehose" icon="lucide--waves" /> 80 - <NavMenu href="/settings" label="Settings" icon="lucide--settings" /> 81 - <ThemeSelection /> 82 - </DropdownMenu> 83 - </MenuProvider> 84 71 </div> 85 72 </header> 86 73 <div class="flex max-w-full min-w-[22rem] flex-col items-center gap-4 text-pretty sm:min-w-[24rem] md:max-w-[48rem]">
-64
src/views/blob.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 2 - import { createResource, createSignal, For, Show } from "solid-js"; 3 - import { Button } from "../components/button"; 4 - 5 - const LIMIT = 1000; 6 - 7 - const BlobView = (props: { pds: string; repo: string }) => { 8 - const [cursor, setCursor] = createSignal<string>(); 9 - let rpc: Client; 10 - 11 - const fetchBlobs = async () => { 12 - if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: props.pds }) }); 13 - const res = await rpc.get("com.atproto.sync.listBlobs", { 14 - params: { 15 - did: props.repo as `did:${string}:${string}`, 16 - limit: LIMIT, 17 - cursor: cursor(), 18 - }, 19 - }); 20 - if (!res.ok) throw new Error(res.data.error); 21 - if (!res.data.cids) return []; 22 - setCursor(res.data.cids.length < LIMIT ? undefined : res.data.cursor); 23 - setBlobs(blobs()?.concat(res.data.cids) ?? res.data.cids); 24 - return res.data.cids; 25 - }; 26 - 27 - const [response, { refetch }] = createResource(fetchBlobs); 28 - const [blobs, setBlobs] = createSignal<string[]>(); 29 - 30 - return ( 31 - <div class="flex flex-col items-center gap-2"> 32 - <Show when={blobs() || response()}> 33 - <p> 34 - {blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""} 35 - </p> 36 - <div class="flex flex-col gap-0.5 font-mono text-sm wrap-anywhere lg:break-normal"> 37 - <For each={blobs()}> 38 - {(cid) => ( 39 - <a 40 - href={`${props.pds}/xrpc/com.atproto.sync.getBlob?did=${props.repo}&cid=${cid}`} 41 - target="_blank" 42 - class="rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 43 - > 44 - <span class="text-blue-400">{cid}</span> 45 - </a> 46 - )} 47 - </For> 48 - </div> 49 - </Show> 50 - <Show when={cursor()}> 51 - <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-3"> 52 - <Show when={!response.loading}> 53 - <Button onClick={() => refetch()}>Load More</Button> 54 - </Show> 55 - <Show when={response.loading}> 56 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 57 - </Show> 58 - </div> 59 - </Show> 60 - </div> 61 - ); 62 - }; 63 - 64 - export { BlobView };
-354
src/views/collection.tsx
··· 1 - import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto"; 2 - import { Client, CredentialManager } from "@atcute/client"; 3 - import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons"; 4 - import * as TID from "@atcute/tid"; 5 - import { A, useParams } from "@solidjs/router"; 6 - import { createEffect, createResource, createSignal, For, Show, untrack } from "solid-js"; 7 - import { createStore } from "solid-js/store"; 8 - import { Button } from "../components/button.jsx"; 9 - import { JSONType, JSONValue } from "../components/json.jsx"; 10 - import { agent } from "../components/login.jsx"; 11 - import { Modal } from "../components/modal.jsx"; 12 - import { StickyOverlay } from "../components/sticky.jsx"; 13 - import { TextInput } from "../components/text-input.jsx"; 14 - import Tooltip from "../components/tooltip.jsx"; 15 - import { setNotif } from "../layout.jsx"; 16 - import { resolvePDS } from "../utils/api.js"; 17 - import { localDateFromTimestamp } from "../utils/date.js"; 18 - 19 - interface AtprotoRecord { 20 - rkey: string; 21 - record: InferXRPCBodyOutput<ComAtprotoRepoGetRecord.mainSchema["output"]>; 22 - timestamp: number | undefined; 23 - toDelete: boolean; 24 - } 25 - 26 - const LIMIT = 100; 27 - 28 - const RecordLink = (props: { record: AtprotoRecord }) => { 29 - const [hover, setHover] = createSignal(false); 30 - const [previewHeight, setPreviewHeight] = createSignal(0); 31 - let rkeyRef!: HTMLSpanElement; 32 - let previewRef!: HTMLSpanElement; 33 - 34 - createEffect(() => { 35 - if (hover()) setPreviewHeight(previewRef.offsetHeight); 36 - }); 37 - 38 - const isOverflowing = (previewHeight: number) => 39 - rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; 40 - 41 - return ( 42 - <span 43 - class="relative flex items-baseline rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 44 - ref={rkeyRef} 45 - onmouseover={() => setHover(true)} 46 - onmouseleave={() => setHover(false)} 47 - > 48 - <span class="text-sm text-blue-400 sm:text-base">{props.record.rkey}</span> 49 - <Show when={props.record.timestamp && props.record.timestamp <= Date.now()}> 50 - <span class="ml-1 text-xs text-neutral-500 dark:text-neutral-400"> 51 - {localDateFromTimestamp(props.record.timestamp!)} 52 - </span> 53 - </Show> 54 - <Show when={hover()}> 55 - <span 56 - ref={previewRef} 57 - class={`dark:bg-dark-300 dark:shadow-dark-800 pointer-events-none absolute left-[50%] z-25 block max-h-[20rem] 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-[28rem] lg:max-w-lg dark:border-neutral-700 ${isOverflowing(previewHeight()) ? "bottom-7" : "top-7"}`} 58 - > 59 - <JSONValue 60 - data={props.record.record.value as JSONType} 61 - repo={props.record.record.uri.split("/")[2]} 62 - /> 63 - </span> 64 - </Show> 65 - </span> 66 - ); 67 - }; 68 - 69 - const CollectionView = () => { 70 - const params = useParams(); 71 - const [cursor, setCursor] = createSignal<string>(); 72 - const [records, setRecords] = createStore<AtprotoRecord[]>([]); 73 - const [filter, setFilter] = createSignal<string>(); 74 - const [batchDelete, setBatchDelete] = createSignal(false); 75 - const [lastSelected, setLastSelected] = createSignal<number>(); 76 - const [reverse, setReverse] = createSignal(false); 77 - const [recreate, setRecreate] = createSignal(false); 78 - const [openDelete, setOpenDelete] = createSignal(false); 79 - const did = params.repo; 80 - let pds: string; 81 - let rpc: Client; 82 - 83 - const fetchRecords = async () => { 84 - if (!pds) pds = await resolvePDS(did); 85 - if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 86 - const res = await rpc.get("com.atproto.repo.listRecords", { 87 - params: { 88 - repo: did as ActorIdentifier, 89 - collection: params.collection as `${string}.${string}.${string}`, 90 - limit: LIMIT, 91 - cursor: cursor(), 92 - reverse: reverse(), 93 - }, 94 - }); 95 - if (!res.ok) throw new Error(res.data.error); 96 - setCursor(res.data.records.length < LIMIT ? undefined : res.data.cursor); 97 - const tmpRecords: AtprotoRecord[] = []; 98 - res.data.records.forEach((record) => { 99 - const rkey = record.uri.split("/").pop()!; 100 - tmpRecords.push({ 101 - rkey: rkey, 102 - record: record, 103 - timestamp: TID.validate(rkey) ? TID.parse(rkey).timestamp / 1000 : undefined, 104 - toDelete: false, 105 - }); 106 - }); 107 - setRecords(records.concat(tmpRecords) ?? tmpRecords); 108 - return res.data.records; 109 - }; 110 - 111 - const [response, { refetch }] = createResource(fetchRecords); 112 - 113 - const deleteRecords = async () => { 114 - const recsToDel = records.filter((record) => record.toDelete); 115 - let writes: Array< 116 - | $type.enforce<ComAtprotoRepoApplyWrites.Delete> 117 - | $type.enforce<ComAtprotoRepoApplyWrites.Create> 118 - > = []; 119 - recsToDel.forEach((record) => { 120 - writes.push({ 121 - $type: "com.atproto.repo.applyWrites#delete", 122 - collection: params.collection as `${string}.${string}.${string}`, 123 - rkey: record.rkey, 124 - }); 125 - if (recreate()) { 126 - writes.push({ 127 - $type: "com.atproto.repo.applyWrites#create", 128 - collection: params.collection as `${string}.${string}.${string}`, 129 - rkey: record.rkey, 130 - value: record.record.value, 131 - }); 132 - } 133 - }); 134 - 135 - const BATCHSIZE = 200; 136 - rpc = new Client({ handler: agent()! }); 137 - for (let i = 0; i < writes.length; i += BATCHSIZE) { 138 - await rpc.post("com.atproto.repo.applyWrites", { 139 - input: { 140 - repo: agent()!.sub, 141 - writes: writes.slice(i, i + BATCHSIZE), 142 - }, 143 - }); 144 - } 145 - setNotif({ 146 - show: true, 147 - icon: "lucide--trash-2", 148 - text: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, 149 - }); 150 - setBatchDelete(false); 151 - setRecords([]); 152 - setCursor(undefined); 153 - setOpenDelete(false); 154 - setRecreate(false); 155 - refetch(); 156 - }; 157 - 158 - const handleSelectionClick = (e: MouseEvent, index: number) => { 159 - if (e.shiftKey && lastSelected() !== undefined) 160 - setRecords( 161 - { 162 - from: lastSelected()! < index ? lastSelected() : index + 1, 163 - to: index > lastSelected()! ? index - 1 : lastSelected(), 164 - }, 165 - "toDelete", 166 - true, 167 - ); 168 - else setLastSelected(index); 169 - }; 170 - 171 - const selectAll = () => 172 - setRecords( 173 - records 174 - .map((record, index) => 175 - JSON.stringify(record.record.value).includes(filter() ?? "") ? index : undefined, 176 - ) 177 - .filter((i) => i !== undefined), 178 - "toDelete", 179 - true, 180 - ); 181 - 182 - return ( 183 - <Show when={records.length || response()}> 184 - <div class="-mt-2 flex w-full flex-col items-center"> 185 - <StickyOverlay> 186 - <div class="flex w-[22rem] items-center gap-1 sm:w-[24rem]"> 187 - <Show when={agent() && agent()?.sub === did}> 188 - <div class="flex items-center"> 189 - <Tooltip 190 - text={batchDelete() ? "Cancel" : "Delete"} 191 - children={ 192 - <button 193 - onclick={() => { 194 - setRecords( 195 - { from: 0, to: untrack(() => records.length) - 1 }, 196 - "toDelete", 197 - false, 198 - ); 199 - setLastSelected(undefined); 200 - setBatchDelete(!batchDelete()); 201 - }} 202 - class="-ml-1 flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 203 - > 204 - <span 205 - class={`iconify text-lg ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `} 206 - ></span> 207 - </button> 208 - } 209 - /> 210 - <Show when={batchDelete()}> 211 - <Tooltip 212 - text="Select all" 213 - children={ 214 - <button 215 - onclick={() => selectAll()} 216 - class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 217 - > 218 - <span class="iconify lucide--copy-check text-lg"></span> 219 - </button> 220 - } 221 - /> 222 - <Tooltip 223 - text="Recreate" 224 - children={ 225 - <button 226 - onclick={() => { 227 - setRecreate(true); 228 - setOpenDelete(true); 229 - }} 230 - class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 231 - > 232 - <span class="iconify lucide--recycle text-lg text-green-500 dark:text-green-400"></span> 233 - </button> 234 - } 235 - /> 236 - <Tooltip 237 - text="Delete" 238 - children={ 239 - <button 240 - onclick={() => { 241 - setRecreate(false); 242 - setOpenDelete(true); 243 - }} 244 - class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 245 - > 246 - <span class="iconify lucide--trash-2 text-lg text-red-500 dark:text-red-400"></span> 247 - </button> 248 - } 249 - /> 250 - </Show> 251 - </div> 252 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 253 - <div class="dark:bg-dark-300 dark:shadow-dark-800 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-300 dark:border-neutral-700 starting:opacity-0"> 254 - <h2 class="mb-2 font-semibold"> 255 - {recreate() ? "Recreate" : "Delete"} {records.filter((r) => r.toDelete).length}{" "} 256 - records? 257 - </h2> 258 - <div class="flex justify-end gap-2"> 259 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 260 - <Button 261 - onClick={deleteRecords} 262 - class={`dark:shadow-dark-800 rounded-lg px-2 py-1.5 text-xs font-semibold text-neutral-200 shadow-xs select-none ${recreate() ? "bg-green-500 hover:bg-green-400 dark:bg-green-600 dark:hover:bg-green-500" : "bg-red-500 hover:bg-red-400 active:bg-red-400"}`} 263 - > 264 - {recreate() ? "Recreate" : "Delete"} 265 - </Button> 266 - </div> 267 - </div> 268 - </Modal> 269 - </Show> 270 - <Tooltip text="Jetstream"> 271 - <A 272 - href={`/jetstream?collections=${params.collection}&dids=${params.repo}`} 273 - class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 274 - > 275 - <span class="iconify lucide--radio-tower text-lg"></span> 276 - </A> 277 - </Tooltip> 278 - <TextInput 279 - placeholder="Filter by substring" 280 - onInput={(e) => setFilter(e.currentTarget.value)} 281 - class="grow" 282 - /> 283 - </div> 284 - <Show when={records.length > 1}> 285 - <div class="flex w-[22rem] items-center justify-between gap-x-2 sm:w-[24rem]"> 286 - <Button 287 - onClick={() => { 288 - setReverse(!reverse()); 289 - setRecords([]); 290 - setCursor(undefined); 291 - refetch(); 292 - }} 293 - > 294 - <span 295 - class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"} text-sm`} 296 - ></span> 297 - Reverse 298 - </Button> 299 - <div> 300 - <Show when={batchDelete()}> 301 - <span>{records.filter((rec) => rec.toDelete).length}</span> 302 - <span>/</span> 303 - </Show> 304 - <span>{records.length} records</span> 305 - </div> 306 - <div class="flex w-[5rem] items-center justify-end"> 307 - <Show when={cursor()}> 308 - <Show when={!response.loading}> 309 - <Button onClick={() => refetch()}>Load More</Button> 310 - </Show> 311 - <Show when={response.loading}> 312 - <div class="iconify lucide--loader-circle w-[5rem] animate-spin text-xl" /> 313 - </Show> 314 - </Show> 315 - </div> 316 - </div> 317 - </Show> 318 - </StickyOverlay> 319 - <div class="flex max-w-full flex-col font-mono"> 320 - <For 321 - each={records.filter((rec) => 322 - filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true, 323 - )} 324 - > 325 - {(record, index) => ( 326 - <> 327 - <Show when={batchDelete()}> 328 - <label 329 - class="flex items-center gap-1 select-none" 330 - onclick={(e) => handleSelectionClick(e, index())} 331 - > 332 - <input 333 - type="checkbox" 334 - checked={record.toDelete} 335 - onchange={(e) => setRecords(index(), "toDelete", e.currentTarget.checked)} 336 - /> 337 - <RecordLink record={record} /> 338 - </label> 339 - </Show> 340 - <Show when={!batchDelete()}> 341 - <A href={`/at://${did}/${params.collection}/${record.rkey}`}> 342 - <RecordLink record={record} /> 343 - </A> 344 - </Show> 345 - </> 346 - )} 347 - </For> 348 - </div> 349 - </div> 350 - </Show> 351 - ); 352 - }; 353 - 354 - export { CollectionView };
+71
src/views/enroll.tsx
··· 1 + import { createSignal, Show } from "solid-js"; 2 + import { useParams } from "@solidjs/router"; 3 + import { Button } from "../components/button.jsx"; 4 + import { TextInput } from "../components/text-input.jsx"; 5 + 6 + const EnrollView = () => { 7 + const params = useParams(); 8 + const did = params.repo; 9 + 10 + const [email, setEmail] = createSignal(""); 11 + const [submitted, setSubmitted] = createSignal(false); 12 + const [submitting, setSubmitting] = createSignal(false); 13 + const [error, setError] = createSignal<string>(); 14 + 15 + const handleSubmit = async (e: Event) => { 16 + e.preventDefault(); 17 + setSubmitting(true); 18 + setError(undefined); 19 + 20 + console.log(`Enrolling ${email()} for DID ${did}`); 21 + 22 + // This is where the call to the backend would go. 23 + // For now, we'll just simulate a successful submission. 24 + await new Promise((resolve) => setTimeout(resolve, 1000)); 25 + 26 + // Assuming the backend call is successful 27 + setSubmitted(true); 28 + setSubmitting(false); 29 + }; 30 + 31 + return ( 32 + <div class="flex w-[22rem] flex-col gap-2 break-words sm:w-[24rem]"> 33 + <Show 34 + when={!submitted()} 35 + fallback={ 36 + <div class="rounded-lg bg-green-100 p-2 text-sm text-green-700 dark:bg-green-200 dark:text-green-600"> 37 + Activate your monitor by clicking the link in the confirmation email. 38 + </div> 39 + } 40 + > 41 + <form class="flex flex-col gap-2" onSubmit={handleSubmit}> 42 + <p class="text-sm"> 43 + Get notified of changes to this DID document. 44 + </p> 45 + <TextInput 46 + type="email" 47 + placeholder="your@email.com" 48 + value={email()} 49 + onInput={(e) => setEmail(e.currentTarget.value)} 50 + required 51 + /> 52 + <Button type="submit" disabled={submitting()}> 53 + <Show when={submitting()} fallback={<>Start Monitoring</>}> 54 + <div class="flex items-center gap-1"> 55 + <div class="iconify lucide--loader-circle animate-spin" /> 56 + Submitting... 57 + </div> 58 + </Show> 59 + </Button> 60 + <Show when={error()}> 61 + <div class="rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-200 dark:text-red-600"> 62 + {error()} 63 + </div> 64 + </Show> 65 + </form> 66 + </Show> 67 + </div> 68 + ); 69 + }; 70 + 71 + export {EnrollView};
-181
src/views/labels.tsx
··· 1 - import { ComAtprotoLabelDefs } from "@atcute/atproto"; 2 - import { Client, CredentialManager } from "@atcute/client"; 3 - import { A, useParams, useSearchParams } from "@solidjs/router"; 4 - import { createResource, createSignal, For, onMount, Show } from "solid-js"; 5 - import { Button } from "../components/button.jsx"; 6 - import { StickyOverlay } from "../components/sticky.jsx"; 7 - import { TextInput } from "../components/text-input.jsx"; 8 - import { labelerCache, resolvePDS } from "../utils/api.js"; 9 - import { localDateFromTimestamp } from "../utils/date.js"; 10 - 11 - const LabelView = () => { 12 - const params = useParams(); 13 - const [searchParams, setSearchParams] = useSearchParams(); 14 - const [cursor, setCursor] = createSignal<string>(); 15 - const [labels, setLabels] = createSignal<ComAtprotoLabelDefs.Label[]>([]); 16 - const [filter, setFilter] = createSignal<string>(); 17 - const [labelCount, setLabelCount] = createSignal(0); 18 - const did = params.repo; 19 - let rpc: Client; 20 - 21 - onMount(async () => { 22 - await resolvePDS(did); 23 - rpc = new Client({ 24 - handler: new CredentialManager({ service: labelerCache[did] }), 25 - }); 26 - refetch(); 27 - }); 28 - 29 - const fetchLabels = async () => { 30 - const uriPatterns = (document.getElementById("patterns") as HTMLInputElement).value; 31 - if (!uriPatterns) return; 32 - const res = await rpc.get("com.atproto.label.queryLabels", { 33 - params: { 34 - uriPatterns: uriPatterns.toString().trim().split(","), 35 - sources: [did as `did:${string}:${string}`], 36 - cursor: cursor(), 37 - }, 38 - }); 39 - if (!res.ok) throw new Error(res.data.error); 40 - setCursor(res.data.labels.length < 50 ? undefined : res.data.cursor); 41 - setLabels(labels().concat(res.data.labels) ?? res.data.labels); 42 - return res.data.labels; 43 - }; 44 - 45 - const [response, { refetch }] = createResource(fetchLabels); 46 - 47 - const initQuery = async () => { 48 - setLabels([]); 49 - setCursor(""); 50 - setSearchParams({ 51 - uriPatterns: (document.getElementById("patterns") as HTMLInputElement).value, 52 - }); 53 - refetch(); 54 - }; 55 - 56 - const filterLabels = () => { 57 - const newFilter = labels().filter((label) => (filter() ? filter() === label.val : true)); 58 - setLabelCount(newFilter.length); 59 - return newFilter; 60 - }; 61 - 62 - return ( 63 - <div class="flex w-full flex-col items-center"> 64 - <form 65 - class="flex w-[22rem] flex-col items-center gap-y-1 sm:w-[24rem]" 66 - onsubmit={(e) => { 67 - e.preventDefault(); 68 - initQuery(); 69 - }} 70 - > 71 - <div class="w-full"> 72 - <label for="patterns" class="ml-0.5 text-sm"> 73 - URI Patterns (comma-separated) 74 - </label> 75 - </div> 76 - <div class="flex w-full items-center gap-x-1"> 77 - <textarea 78 - id="patterns" 79 - name="patterns" 80 - spellcheck={false} 81 - rows={3} 82 - value={searchParams.uriPatterns ?? "*"} 83 - class="dark:bg-dark-100 dark:shadow-dark-800 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 shadow-xs focus:outline-[1.5px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200" 84 - /> 85 - <div class="flex justify-center"> 86 - <Show when={!response.loading}> 87 - <button 88 - type="submit" 89 - class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 90 - > 91 - <span class="iconify lucide--search text-lg"></span> 92 - </button> 93 - </Show> 94 - <Show when={response.loading}> 95 - <div class="m-1 flex items-center"> 96 - <span class="iconify lucide--loader-circle animate-spin text-lg"></span> 97 - </div> 98 - </Show> 99 - </div> 100 - </div> 101 - </form> 102 - <StickyOverlay> 103 - <TextInput 104 - placeholder="Filter by label" 105 - onInput={(e) => setFilter(e.currentTarget.value)} 106 - class="w-[22rem] sm:w-[24rem]" 107 - /> 108 - <div class="flex items-center gap-x-2"> 109 - <Show when={labelCount() && labels().length}> 110 - <div> 111 - <span> 112 - {labelCount()} label{labelCount() > 1 ? "s" : ""} 113 - </span> 114 - </div> 115 - </Show> 116 - <Show when={cursor()}> 117 - <div class="flex h-[2rem] w-[5.5rem] items-center justify-center text-nowrap"> 118 - <Show when={!response.loading}> 119 - <Button onClick={() => refetch()}>Load More</Button> 120 - </Show> 121 - <Show when={response.loading}> 122 - <div class="iconify lucide--loader-circle animate-spin text-xl" /> 123 - </Show> 124 - </div> 125 - </Show> 126 - </div> 127 - </StickyOverlay> 128 - <Show when={labels().length}> 129 - <div class="flex max-w-full min-w-[22rem] flex-col gap-2 divide-y-[0.5px] divide-neutral-400 text-sm wrap-anywhere whitespace-pre-wrap sm:min-w-[24rem] dark:divide-neutral-600"> 130 - <For each={filterLabels()}> 131 - {(label) => ( 132 - <div class="flex items-center justify-between gap-2 pb-2"> 133 - <div class="flex flex-col"> 134 - <div class="flex items-center gap-x-2"> 135 - <div class="min-w-[5rem] font-semibold">URI</div> 136 - <A 137 - href={`/at://${label.uri.replace("at://", "")}`} 138 - class="text-blue-400 hover:underline active:underline" 139 - > 140 - {label.uri} 141 - </A> 142 - </div> 143 - <Show when={label.cid}> 144 - <div class="flex items-center gap-x-2"> 145 - <div class="min-w-[5rem] font-semibold">CID</div> 146 - {label.cid} 147 - </div> 148 - </Show> 149 - <div class="flex items-center gap-x-2"> 150 - <div class="min-w-[5rem] font-semibold">Label</div> 151 - {label.val} 152 - </div> 153 - <div class="flex items-center gap-x-2"> 154 - <div class="min-w-[5rem] font-semibold">Created</div> 155 - {localDateFromTimestamp(new Date(label.cts).getTime())} 156 - </div> 157 - <Show when={label.exp}> 158 - {(exp) => ( 159 - <div class="flex items-center gap-x-2"> 160 - <div class="min-w-[5rem] font-semibold">Expires</div> 161 - {localDateFromTimestamp(new Date(exp()).getTime())} 162 - </div> 163 - )} 164 - </Show> 165 - </div> 166 - <Show when={label.neg}> 167 - <div class="iconify lucide--minus shrink-0 text-lg text-red-500 dark:text-red-400" /> 168 - </Show> 169 - </div> 170 - )} 171 - </For> 172 - </div> 173 - </Show> 174 - <Show when={!labels().length && !response.loading && searchParams.uriPatterns}> 175 - <div class="mt-2">No results</div> 176 - </Show> 177 - </div> 178 - ); 179 - }; 180 - 181 - export { LabelView };
-120
src/views/pds.tsx
··· 1 - import { ComAtprotoServerDescribeServer, ComAtprotoSyncListRepos } from "@atcute/atproto"; 2 - import { Client, CredentialManager } from "@atcute/client"; 3 - import { InferXRPCBodyOutput } from "@atcute/lexicons"; 4 - import * as TID from "@atcute/tid"; 5 - import { A, useParams } from "@solidjs/router"; 6 - import { createResource, createSignal, For, Show } from "solid-js"; 7 - import { Button } from "../components/button"; 8 - import { setPDS } from "../components/navbar"; 9 - import Tooltip from "../components/tooltip"; 10 - import { localDateFromTimestamp } from "../utils/date"; 11 - 12 - const LIMIT = 1000; 13 - 14 - const PdsView = () => { 15 - const params = useParams(); 16 - const [version, setVersion] = createSignal<string>(); 17 - const [serverInfos, setServerInfos] = 18 - createSignal<InferXRPCBodyOutput<ComAtprotoServerDescribeServer.mainSchema["output"]>>(); 19 - const [cursor, setCursor] = createSignal<string>(); 20 - setPDS(params.pds); 21 - const pds = params.pds.startsWith("localhost") ? `http://${params.pds}` : `https://${params.pds}`; 22 - const rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 23 - 24 - const getVersion = async () => { 25 - // @ts-expect-error: undocumented endpoint 26 - const res = await rpc.get("_health", {}); 27 - setVersion((res.data as any).version); 28 - }; 29 - 30 - const fetchRepos = async () => { 31 - getVersion(); 32 - const describeRes = await rpc.get("com.atproto.server.describeServer"); 33 - if (!describeRes.ok) console.error(describeRes.data.error); 34 - else setServerInfos(describeRes.data); 35 - const res = await rpc.get("com.atproto.sync.listRepos", { 36 - params: { limit: LIMIT, cursor: cursor() }, 37 - }); 38 - if (!res.ok) throw new Error(res.data.error); 39 - setCursor(res.data.repos.length < LIMIT ? undefined : res.data.cursor); 40 - setRepos(repos()?.concat(res.data.repos) ?? res.data.repos); 41 - return res.data; 42 - }; 43 - 44 - const [response, { refetch }] = createResource(fetchRepos); 45 - const [repos, setRepos] = createSignal<ComAtprotoSyncListRepos.Repo[]>(); 46 - 47 - return ( 48 - <Show when={repos() || response()}> 49 - <div class="flex w-[22rem] flex-col sm:w-[24rem]"> 50 - <Show when={version()}> 51 - {(version) => ( 52 - <div class="flex items-baseline gap-x-1"> 53 - <span class="font-semibold">Version</span> 54 - <span class="truncate text-sm">{version()}</span> 55 - </div> 56 - )} 57 - </Show> 58 - <Show when={serverInfos()}> 59 - {(server) => ( 60 - <> 61 - <Show when={server().inviteCodeRequired}> 62 - <span class="font-semibold">Invite Code Required</span> 63 - </Show> 64 - <Show when={server().phoneVerificationRequired}> 65 - <span class="font-semibold">Phone Verification Required</span> 66 - </Show> 67 - <Show when={server().availableUserDomains.length}> 68 - <div class="flex flex-col"> 69 - <span class="font-semibold">Available User Domains</span> 70 - <For each={server().availableUserDomains}> 71 - {(domain) => <span class="text-sm wrap-anywhere">{domain}</span>} 72 - </For> 73 - </div> 74 - </Show> 75 - </> 76 - )} 77 - </Show> 78 - <p class="w-full font-semibold">{repos()?.length} Repositories</p> 79 - <For each={repos()}> 80 - {(repo) => ( 81 - <A 82 - href={`/at://${repo.did}`} 83 - classList={{ 84 - "rounded items-center text-sm gap-1 flex justify-between font-mono relative hover:bg-neutral-200 dark:hover:bg-neutral-700 active:bg-neutral-300 dark:active:bg-neutral-600": true, 85 - "text-blue-400": repo.active, 86 - "text-neutral-400 dark:text-neutral-500": !repo.active, 87 - }} 88 - > 89 - <Show when={!repo.active}> 90 - <div class="absolute -left-4"> 91 - <Tooltip text={repo.status ?? "Unknown status"}> 92 - <span class="iconify lucide--unplug"></span> 93 - </Tooltip> 94 - </div> 95 - </Show> 96 - <span class="text-sm">{repo.did}</span> 97 - <Show when={TID.validate(repo.rev)}> 98 - <span class="text-xs text-neutral-500 dark:text-neutral-400"> 99 - {localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000).split(" ")[0]} 100 - </span> 101 - </Show> 102 - </A> 103 - )} 104 - </For> 105 - </div> 106 - <Show when={cursor()}> 107 - <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-3"> 108 - <Show when={!response.loading}> 109 - <Button onClick={() => refetch()}>Load More</Button> 110 - </Show> 111 - <Show when={response.loading}> 112 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 113 - </Show> 114 - </div> 115 - </Show> 116 - </Show> 117 - ); 118 - }; 119 - 120 - export { PdsView };
-227
src/views/record.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 2 - import { lexiconDoc } from "@atcute/lexicon-doc"; 3 - import { ActorIdentifier, is, ResourceUri } from "@atcute/lexicons"; 4 - import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 5 - import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 6 - import { Backlinks } from "../components/backlinks.jsx"; 7 - import { Button } from "../components/button.jsx"; 8 - import { RecordEditor } from "../components/create.jsx"; 9 - import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 10 - import { JSONValue } from "../components/json.jsx"; 11 - import { agent } from "../components/login.jsx"; 12 - import { Modal } from "../components/modal.jsx"; 13 - import { pds, setCID, setValidRecord, setValidSchema, validRecord } from "../components/navbar.jsx"; 14 - import Tooltip from "../components/tooltip.jsx"; 15 - import { setNotif } from "../layout.jsx"; 16 - import { didDocCache, resolvePDS } from "../utils/api.js"; 17 - import { AtUri, uriTemplates } from "../utils/templates.js"; 18 - import { lexicons } from "../utils/types/lexicons.js"; 19 - import { verifyRecord } from "../utils/verify.js"; 20 - 21 - export const RecordView = () => { 22 - const location = useLocation(); 23 - const navigate = useNavigate(); 24 - const params = useParams(); 25 - const [openDelete, setOpenDelete] = createSignal(false); 26 - const [notice, setNotice] = createSignal(""); 27 - const [externalLink, setExternalLink] = createSignal< 28 - { label: string; link: string; icon?: string } | undefined 29 - >(); 30 - const did = params.repo; 31 - let rpc: Client; 32 - 33 - const fetchRecord = async () => { 34 - setCID(undefined); 35 - setValidRecord(undefined); 36 - setValidSchema(undefined); 37 - const pds = await resolvePDS(did); 38 - rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 39 - const res = await rpc.get("com.atproto.repo.getRecord", { 40 - params: { 41 - repo: did as ActorIdentifier, 42 - collection: params.collection as `${string}.${string}.${string}`, 43 - rkey: params.rkey, 44 - }, 45 - }); 46 - if (!res.ok) { 47 - setValidRecord(false); 48 - setNotice(res.data.error); 49 - throw new Error(res.data.error); 50 - } 51 - setCID(res.data.cid); 52 - setExternalLink(checkUri(res.data.uri, res.data.value)); 53 - verify(res.data); 54 - 55 - return res.data; 56 - }; 57 - 58 - const verify = async (record: { 59 - uri: ResourceUri; 60 - value: Record<string, unknown>; 61 - cid?: string | undefined; 62 - }) => { 63 - try { 64 - if (params.collection in lexicons) { 65 - if (is(lexicons[params.collection], record.value)) setValidSchema(true); 66 - else setValidSchema(false); 67 - } else if (params.collection === "com.atproto.lexicon.schema") { 68 - try { 69 - lexiconDoc.parse(record.value, { mode: "passthrough" }); 70 - setValidSchema(true); 71 - } catch (e) { 72 - console.error(e); 73 - setValidSchema(false); 74 - } 75 - } 76 - const { errors } = await verifyRecord({ 77 - rpc: rpc, 78 - uri: record.uri, 79 - cid: record.cid!, 80 - record: record.value, 81 - didDoc: didDocCache[record.uri.split("/")[2]], 82 - }); 83 - 84 - if (errors.length > 0) { 85 - console.warn(errors); 86 - setNotice(`Invalid record: ${errors.map((e) => e.message).join("\n")}`); 87 - } 88 - setValidRecord(errors.length === 0); 89 - } catch (err) { 90 - console.error(err); 91 - setValidRecord(false); 92 - } 93 - }; 94 - 95 - const [record, { refetch }] = createResource(fetchRecord); 96 - 97 - const deleteRecord = async () => { 98 - rpc = new Client({ handler: agent()! }); 99 - await rpc.post("com.atproto.repo.deleteRecord", { 100 - input: { 101 - repo: params.repo as ActorIdentifier, 102 - collection: params.collection as `${string}.${string}.${string}`, 103 - rkey: params.rkey, 104 - }, 105 - }); 106 - setNotif({ show: true, icon: "lucide--trash-2", text: "Record deleted" }); 107 - navigate(`/at://${params.repo}/${params.collection}`); 108 - }; 109 - 110 - const checkUri = (uri: string, record: any) => { 111 - const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"] 112 - if (uriParts.length != 5) return undefined; 113 - if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined; 114 - const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] }; 115 - const template = uriTemplates[parsedUri.collection]; 116 - if (!template) return undefined; 117 - return template(parsedUri, record); 118 - }; 119 - 120 - return ( 121 - <Show when={record()} keyed> 122 - <div class="flex w-full flex-col items-center"> 123 - <div class="dark:shadow-dark-800 dark:bg-dark-300 mb-3 flex w-[22rem] justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 shadow-xs sm:w-[24rem] dark:border-neutral-700"> 124 - <div class="flex gap-3 text-sm"> 125 - <A 126 - classList={{ 127 - "flex items-center gap-1 border-b-2": true, 128 - "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 129 - !!location.hash && location.hash !== "#record", 130 - }} 131 - href={`/at://${did}/${params.collection}/${params.rkey}#record`} 132 - > 133 - <div class="iconify lucide--file-json" /> 134 - Record 135 - </A> 136 - <A 137 - classList={{ 138 - "flex items-center gap-1 border-b-2": true, 139 - "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 140 - location.hash !== "#backlinks", 141 - }} 142 - href={`/at://${did}/${params.collection}/${params.rkey}#backlinks`} 143 - > 144 - <div class="iconify lucide--send-to-back" /> 145 - Backlinks 146 - </A> 147 - </div> 148 - <div class="flex gap-1"> 149 - <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 150 - <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 151 - <Tooltip text="Delete"> 152 - <button 153 - class="flex items-center rounded-sm p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 154 - onclick={() => setOpenDelete(true)} 155 - > 156 - <span class="iconify lucide--trash-2"></span> 157 - </button> 158 - </Tooltip> 159 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 160 - <div class="dark:bg-dark-300 dark:shadow-dark-800 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-300 dark:border-neutral-700 starting:opacity-0"> 161 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 162 - <div class="flex justify-end gap-2"> 163 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 164 - <Button 165 - onClick={deleteRecord} 166 - class="dark:shadow-dark-800 rounded-lg bg-red-500 px-2 py-1.5 text-xs font-semibold text-neutral-200 shadow-xs select-none hover:bg-red-400 active:bg-red-400" 167 - > 168 - Delete 169 - </Button> 170 - </div> 171 - </div> 172 - </Modal> 173 - </Show> 174 - <MenuProvider> 175 - <DropdownMenu 176 - icon="lucide--ellipsis-vertical " 177 - buttonClass="rounded-sm p-1" 178 - menuClass="top-8 p-2 text-sm" 179 - > 180 - <CopyMenu 181 - copyContent={JSON.stringify(record()?.value, null, 2)} 182 - label="Copy record" 183 - icon="lucide--copy" 184 - /> 185 - <Show when={externalLink()}> 186 - {(externalLink) => ( 187 - <NavMenu 188 - href={externalLink()?.link} 189 - icon={`${externalLink().icon ?? "lucide--app-window"}`} 190 - label={`Open on ${externalLink().label}`} 191 - newTab 192 - /> 193 - )} 194 - </Show> 195 - <NavMenu 196 - href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`} 197 - icon="lucide--external-link" 198 - label="Record on PDS" 199 - newTab 200 - /> 201 - </DropdownMenu> 202 - </MenuProvider> 203 - </div> 204 - </div> 205 - <Show when={!location.hash || location.hash === "#record"}> 206 - <Show when={validRecord() === false}> 207 - <div class="mb-2 break-words text-red-500 dark:text-red-400">{notice()}</div> 208 - </Show> 209 - <div class="w-[22rem] font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:w-full sm:text-sm"> 210 - <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} /> 211 - </div> 212 - </Show> 213 - <Show when={location.hash === "#backlinks"}> 214 - <ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}> 215 - <Suspense 216 - fallback={ 217 - <div class="iconify lucide--loader-circle animate-spin self-center text-xl" /> 218 - } 219 - > 220 - <Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} /> 221 - </Suspense> 222 - </ErrorBoundary> 223 - </Show> 224 - </div> 225 - </Show> 226 - ); 227 - };
-537
src/views/repo.tsx
··· 1 - import { Client, CredentialManager } from "@atcute/client"; 2 - import { parsePublicMultikey } from "@atcute/crypto"; 3 - import { 4 - CompatibleOperationOrTombstone, 5 - defs, 6 - IndexedEntry, 7 - processIndexedEntryLog, 8 - } from "@atcute/did-plc"; 9 - import { DidDocument } from "@atcute/identity"; 10 - import { ActorIdentifier, Handle } from "@atcute/lexicons"; 11 - import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 12 - import { createResource, createSignal, ErrorBoundary, For, Show, Suspense } from "solid-js"; 13 - import { Backlinks } from "../components/backlinks.jsx"; 14 - import { Button } from "../components/button.jsx"; 15 - import { TextInput } from "../components/text-input.jsx"; 16 - import Tooltip from "../components/tooltip.jsx"; 17 - import { didDocCache, resolveHandle, resolvePDS } from "../utils/api.js"; 18 - import { localDateFromTimestamp } from "../utils/date.js"; 19 - import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 20 - import { BlobView } from "./blob.jsx"; 21 - 22 - type Tab = "collections" | "backlinks" | "identity" | "blobs"; 23 - type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; 24 - 25 - const PlcLogView = (props: { 26 - did: string; 27 - plcOps: [IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]; 28 - }) => { 29 - const [activePlcEvent, setActivePlcEvent] = createSignal<PlcEvent | undefined>(); 30 - 31 - const FilterButton = (props: { icon: string; event: PlcEvent }) => ( 32 - <button 33 - classList={{ 34 - "flex items-center rounded-full p-1.5": true, 35 - "bg-neutral-700 dark:bg-neutral-200": activePlcEvent() === props.event, 36 - }} 37 - onclick={() => setActivePlcEvent(activePlcEvent() === props.event ? undefined : props.event)} 38 - > 39 - <span 40 - class={`${props.icon} ${activePlcEvent() === props.event ? "text-neutral-200 dark:text-neutral-900" : ""}`} 41 - ></span> 42 - </button> 43 - ); 44 - 45 - const DiffItem = (props: { diff: DiffEntry }) => { 46 - const diff = props.diff; 47 - let title = "Unknown log entry"; 48 - let icon = "lucide--circle-help"; 49 - let value = ""; 50 - 51 - if (diff.type === "identity_created") { 52 - icon = "lucide--bell"; 53 - title = `Identity created`; 54 - } else if (diff.type === "identity_tombstoned") { 55 - icon = "lucide--skull"; 56 - title = `Identity tombstoned`; 57 - } else if (diff.type === "handle_added" || diff.type === "handle_removed") { 58 - icon = "lucide--at-sign"; 59 - title = diff.type === "handle_added" ? "Alias added" : "Alias removed"; 60 - value = diff.handle; 61 - } else if (diff.type === "handle_changed") { 62 - icon = "lucide--at-sign"; 63 - title = "Alias updated"; 64 - value = `${diff.prev_handle} โ†’ ${diff.next_handle}`; 65 - } else if (diff.type === "rotation_key_added" || diff.type === "rotation_key_removed") { 66 - icon = "lucide--key-round"; 67 - title = diff.type === "rotation_key_added" ? "Rotation key added" : "Rotation key removed"; 68 - value = diff.rotation_key; 69 - } else if (diff.type === "service_added" || diff.type === "service_removed") { 70 - icon = "lucide--hard-drive"; 71 - title = `Service ${diff.service_id} ${diff.type === "service_added" ? "added" : "removed"}`; 72 - value = `${diff.service_endpoint}`; 73 - } else if (diff.type === "service_changed") { 74 - icon = "lucide--hard-drive"; 75 - title = `Service ${diff.service_id} updated`; 76 - value = `${diff.prev_service_endpoint} โ†’ ${diff.next_service_endpoint}`; 77 - } else if ( 78 - diff.type === "verification_method_added" || 79 - diff.type === "verification_method_removed" 80 - ) { 81 - icon = "lucide--shield-check"; 82 - title = `Verification method ${diff.method_id} ${diff.type === "verification_method_added" ? "added" : "removed"}`; 83 - value = `${diff.method_key}`; 84 - } else if (diff.type === "verification_method_changed") { 85 - icon = "lucide--shield-check"; 86 - title = `Verification method ${diff.method_id} updated`; 87 - value = `${diff.prev_method_key} โ†’ ${diff.next_method_key}`; 88 - } 89 - 90 - return ( 91 - <div class="grid grid-cols-[min-content_1fr] items-center gap-x-1"> 92 - <div class={icon + ` iconify shrink-0`} /> 93 - <p 94 - classList={{ 95 - "font-semibold": true, 96 - "text-neutral-400 line-through dark:text-neutral-600": diff.orig.nullified, 97 - }} 98 - > 99 - {title} 100 - </p> 101 - <div></div> 102 - {value} 103 - </div> 104 - ); 105 - }; 106 - 107 - return ( 108 - <> 109 - <div class="flex items-center justify-between"> 110 - <div class="flex items-center gap-1"> 111 - <div class="iconify lucide--filter" /> 112 - <div class="dark:shadow-dark-800 dark:bg-dark-300 flex w-fit items-center rounded-full border-[0.5px] border-neutral-300 bg-neutral-50 shadow-xs dark:border-neutral-700"> 113 - <FilterButton icon="iconify lucide--at-sign" event="handle" /> 114 - <FilterButton icon="iconify lucide--key-round" event="rotation_key" /> 115 - <FilterButton icon="iconify lucide--hard-drive" event="service" /> 116 - <FilterButton icon="iconify lucide--shield-check" event="verification_method" /> 117 - </div> 118 - </div> 119 - <Tooltip text="Audit log"> 120 - <a 121 - href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${props.did}/log/audit`} 122 - target="_blank" 123 - class="-mr-1 flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 124 - > 125 - <span class="iconify lucide--external-link"></span> 126 - </a> 127 - </Tooltip> 128 - </div> 129 - <div class="flex flex-col gap-1 text-sm"> 130 - <For each={props.plcOps}> 131 - {([entry, diffs]) => ( 132 - <Show 133 - when={!activePlcEvent() || diffs.find((d) => d.type.startsWith(activePlcEvent()!))} 134 - > 135 - <div class="flex flex-col"> 136 - <span class="text-neutral-500 dark:text-neutral-400"> 137 - {localDateFromTimestamp(new Date(entry.createdAt).getTime())} 138 - </span> 139 - {diffs.map((diff) => ( 140 - <Show when={!activePlcEvent() || diff.type.startsWith(activePlcEvent()!)}> 141 - <DiffItem diff={diff} /> 142 - </Show> 143 - ))} 144 - </div> 145 - </Show> 146 - )} 147 - </For> 148 - </div> 149 - </> 150 - ); 151 - }; 152 - 153 - const RepoView = () => { 154 - const params = useParams(); 155 - const location = useLocation(); 156 - const navigate = useNavigate(); 157 - const [error, setError] = createSignal<string>(); 158 - const [downloading, setDownloading] = createSignal(false); 159 - const [didDoc, setDidDoc] = createSignal<DidDocument>(); 160 - const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 161 - const [filter, setFilter] = createSignal<string>(); 162 - const [plcOps, setPlcOps] = 163 - createSignal<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>(); 164 - const [showPlcLogs, setShowPlcLogs] = createSignal(false); 165 - const [loading, setLoading] = createSignal(false); 166 - const [notice, setNotice] = createSignal<string>(); 167 - let rpc: Client; 168 - let pds: string; 169 - const did = params.repo; 170 - 171 - const RepoTab = (props: { tab: Tab; label: string; icon: string }) => ( 172 - <A 173 - classList={{ 174 - "flex items-center border-b-2 gap-1": true, 175 - "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 176 - (location.hash !== `#${props.tab}` && !!location.hash) || 177 - (!location.hash && props.tab !== "collections"), 178 - }} 179 - href={`/at://${params.repo}#${props.tab}`} 180 - > 181 - <div class={"iconify " + props.icon} /> 182 - {props.label} 183 - </A> 184 - ); 185 - 186 - const fetchRepo = async () => { 187 - try { 188 - pds = await resolvePDS(did); 189 - } catch { 190 - try { 191 - const did = await resolveHandle(params.repo as Handle); 192 - navigate(location.pathname.replace(params.repo, did)); 193 - } catch { 194 - navigate(`/${did}`); 195 - } 196 - } 197 - setDidDoc(didDocCache[did] as DidDocument); 198 - 199 - rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 200 - const res = await rpc.get("com.atproto.repo.describeRepo", { 201 - params: { repo: did as ActorIdentifier }, 202 - }); 203 - if (res.ok) { 204 - const collections: Record<string, { hidden: boolean; nsids: string[] }> = {}; 205 - res.data.collections.forEach((c) => { 206 - const nsid = c.split("."); 207 - if (nsid.length > 2) { 208 - const authority = `${nsid[0]}.${nsid[1]}`; 209 - collections[authority] = { 210 - nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")), 211 - hidden: false, 212 - }; 213 - } 214 - }); 215 - setNsids(collections); 216 - } else { 217 - console.error(res.data.error); 218 - switch (res.data.error) { 219 - case "RepoDeactivated": 220 - setError("This repository has been deactivated"); 221 - break; 222 - case "RepoTakendown": 223 - setError("This repository has been taken down"); 224 - break; 225 - default: 226 - setError("This repository is unreachable"); 227 - } 228 - navigate(`/at://${params.repo}#identity`); 229 - } 230 - 231 - return res.data; 232 - }; 233 - 234 - const [repo] = createResource(fetchRepo); 235 - 236 - const downloadRepo = async () => { 237 - try { 238 - setDownloading(true); 239 - const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`); 240 - if (!response.ok) { 241 - throw new Error(`HTTP error status: ${response.status}`); 242 - } 243 - 244 - const blob = await response.blob(); 245 - const url = window.URL.createObjectURL(blob); 246 - const a = document.createElement("a"); 247 - a.href = url; 248 - a.download = `${did}-${new Date().toISOString()}.car`; 249 - document.body.appendChild(a); 250 - a.click(); 251 - 252 - window.URL.revokeObjectURL(url); 253 - document.body.removeChild(a); 254 - } catch (error) { 255 - console.error("Download failed:", error); 256 - } 257 - setDownloading(false); 258 - }; 259 - 260 - const toggleCollection = (authority: string) => { 261 - setNsids({ 262 - ...nsids(), 263 - [authority]: { ...nsids()![authority], hidden: !nsids()![authority].hidden }, 264 - }); 265 - }; 266 - 267 - return ( 268 - <Show when={repo()}> 269 - <div class="flex w-[22rem] flex-col gap-2 break-words sm:w-[24rem]"> 270 - <Show when={error()}> 271 - <div class="rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-200 dark:text-red-600"> 272 - {error()} 273 - </div> 274 - </Show> 275 - <div 276 - class={`dark:shadow-dark-800 dark:bg-dark-300 flex ${error() ? "justify-around" : "justify-between"} rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm shadow-xs dark:border-neutral-700`} 277 - > 278 - <Show when={!error()}> 279 - <RepoTab tab="collections" label="Collections" icon="lucide--folder-open" /> 280 - </Show> 281 - <RepoTab tab="identity" label="Identity" icon="lucide--id-card" /> 282 - <Show when={!error()}> 283 - <RepoTab tab="blobs" label="Blobs" icon="lucide--file-digit" /> 284 - </Show> 285 - <RepoTab tab="backlinks" label="Backlinks" icon="lucide--send-to-back" /> 286 - </div> 287 - <Show when={location.hash === "#backlinks"}> 288 - <ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}> 289 - <Suspense 290 - fallback={ 291 - <div class="iconify lucide--loader-circle animate-spin self-center text-xl" /> 292 - } 293 - > 294 - <Backlinks target={did} /> 295 - </Suspense> 296 - </ErrorBoundary> 297 - </Show> 298 - <Show when={location.hash === "#blobs"}> 299 - <ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}> 300 - <Suspense 301 - fallback={ 302 - <div class="iconify lucide--loader-circle animate-spin self-center text-xl" /> 303 - } 304 - > 305 - <BlobView pds={pds!} repo={did} /> 306 - </Suspense> 307 - </ErrorBoundary> 308 - </Show> 309 - <Show when={nsids() && (!location.hash || location.hash === "#collections")}> 310 - <div class="flex items-center gap-1"> 311 - <Tooltip text="Jetstream"> 312 - <A 313 - href={`/jetstream?dids=${params.repo}`} 314 - class="-ml-1 flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 315 - > 316 - <span class="iconify lucide--radio-tower text-lg"></span> 317 - </A> 318 - </Tooltip> 319 - <TextInput 320 - placeholder="Filter collections" 321 - onInput={(e) => setFilter(e.currentTarget.value)} 322 - class="grow" 323 - /> 324 - </div> 325 - <div class="flex flex-col font-mono"> 326 - <div class="grid grid-cols-[min-content_1fr] items-center gap-x-2 overflow-hidden text-sm"> 327 - <For 328 - each={Object.keys(nsids() ?? {}).filter((authority) => 329 - filter() ? 330 - authority.startsWith(filter()!) || filter()?.startsWith(authority) 331 - : true, 332 - )} 333 - > 334 - {(authority) => ( 335 - <> 336 - <button onclick={() => toggleCollection(authority)} class="flex items-center"> 337 - <span 338 - classList={{ 339 - "iconify lucide--chevron-down text-lg transition-transform": true, 340 - "-rotate-90": nsids()?.[authority].hidden, 341 - }} 342 - ></span> 343 - </button> 344 - <button 345 - class="bg-transparent text-left wrap-anywhere" 346 - onclick={() => toggleCollection(authority)} 347 - > 348 - {authority} 349 - </button> 350 - <Show when={!nsids()?.[authority].hidden}> 351 - <div></div> 352 - <div class="flex flex-col"> 353 - <For 354 - each={nsids()?.[authority].nsids.filter((nsid) => 355 - filter() ? 356 - nsid.startsWith(filter()!.split(".").slice(2).join(".")) 357 - : true, 358 - )} 359 - > 360 - {(nsid) => ( 361 - <A 362 - href={`/at://${did}/${authority}.${nsid}`} 363 - class="text-blue-400 hover:underline active:underline" 364 - > 365 - {authority}.{nsid} 366 - </A> 367 - )} 368 - </For> 369 - </div> 370 - </Show> 371 - </> 372 - )} 373 - </For> 374 - </div> 375 - </div> 376 - </Show> 377 - <Show when={location.hash === "#identity"}> 378 - <Show when={didDoc()}> 379 - {(didDocument) => ( 380 - <div class="flex flex-col gap-y-2 wrap-anywhere"> 381 - <div class="flex flex-col gap-y-1"> 382 - <div class="flex items-baseline justify-between gap-2"> 383 - <div> 384 - <div class="flex items-center gap-1"> 385 - <div class="iconify lucide--id-card" /> 386 - <p class="font-semibold">ID</p> 387 - </div> 388 - <div class="text-sm">{didDocument().id}</div> 389 - </div> 390 - <Tooltip text="DID document"> 391 - <a 392 - href={ 393 - did.startsWith("did:plc") ? 394 - `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 395 - : `https://${did.split("did:web:")[1]}/.well-known/did.json` 396 - } 397 - target="_blank" 398 - class="-mr-1 flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 399 - > 400 - <span class="iconify lucide--external-link"></span> 401 - </a> 402 - </Tooltip> 403 - </div> 404 - <div> 405 - <div class="flex items-center gap-1"> 406 - <div class="iconify lucide--at-sign" /> 407 - <p class="font-semibold">Aliases</p> 408 - </div> 409 - <ul> 410 - <For each={didDocument().alsoKnownAs}> 411 - {(alias) => <li class="text-sm">{alias}</li>} 412 - </For> 413 - </ul> 414 - </div> 415 - <div> 416 - <div class="flex items-center gap-1"> 417 - <div class="iconify lucide--hard-drive" /> 418 - <p class="font-semibold">Services</p> 419 - </div> 420 - <ul> 421 - <For each={didDocument().service}> 422 - {(service) => ( 423 - <li class="flex flex-col text-sm"> 424 - <span>#{service.id.split("#")[1]}</span> 425 - <a 426 - class="w-fit text-blue-400 hover:underline active:underline" 427 - href={service.serviceEndpoint.toString()} 428 - target="_blank" 429 - > 430 - {service.serviceEndpoint.toString()} 431 - </a> 432 - </li> 433 - )} 434 - </For> 435 - </ul> 436 - </div> 437 - <div> 438 - <div class="flex items-center gap-1"> 439 - <div class="iconify lucide--shield-check" /> 440 - <p class="font-semibold">Verification methods</p> 441 - </div> 442 - <ul> 443 - <For each={didDocument().verificationMethod}> 444 - {(verif) => ( 445 - <Show when={verif.publicKeyMultibase}> 446 - {(key) => ( 447 - <li class="flex flex-col text-sm"> 448 - <span class="flex justify-between gap-1"> 449 - <span>#{verif.id.split("#")[1]}</span> 450 - <span class="flex items-center gap-0.5"> 451 - <div class="iconify lucide--key-round" /> 452 - <ErrorBoundary fallback={<>unknown</>}> 453 - {parsePublicMultikey(key()).type} 454 - </ErrorBoundary> 455 - </span> 456 - </span> 457 - <span class="truncate text-xs">{key()}</span> 458 - </li> 459 - )} 460 - </Show> 461 - )} 462 - </For> 463 - </ul> 464 - </div> 465 - </div> 466 - <div class="flex justify-between"> 467 - <Show when={did.startsWith("did:plc")}> 468 - <div class="flex items-center gap-1"> 469 - <Button 470 - onClick={async () => { 471 - if (!plcOps()) { 472 - setLoading(true); 473 - const response = await fetch( 474 - `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`, 475 - ); 476 - const json = await response.json(); 477 - try { 478 - const logs = defs.indexedEntryLog.parse(json); 479 - try { 480 - await processIndexedEntryLog(did as any, logs); 481 - } catch (e) { 482 - console.error(e); 483 - } 484 - const opHistory = createOperationHistory(logs).reverse(); 485 - setPlcOps(Array.from(groupBy(opHistory, (item) => item.orig))); 486 - setLoading(false); 487 - } catch (e: any) { 488 - setNotice(e); 489 - console.error(e); 490 - setLoading(false); 491 - } 492 - } 493 - 494 - setShowPlcLogs(!showPlcLogs()); 495 - }} 496 - > 497 - <span class="iconify lucide--logs text-sm"></span> 498 - {showPlcLogs() ? "Hide" : "Show"} PLC Logs 499 - </Button> 500 - <Show when={loading()}> 501 - <div class="iconify lucide--loader-circle animate-spin text-xl" /> 502 - </Show> 503 - </div> 504 - </Show> 505 - <Show when={error()?.length === 0 || error() === undefined}> 506 - <div 507 - classList={{ 508 - "flex items-center gap-1": true, 509 - "flex-row-reverse": did.startsWith("did:web"), 510 - }} 511 - > 512 - <Show when={downloading()}> 513 - <div class="iconify lucide--loader-circle animate-spin text-xl" /> 514 - </Show> 515 - <Button onClick={() => downloadRepo()}> 516 - <span class="iconify lucide--download text-sm"></span> 517 - Export Repo 518 - </Button> 519 - </div> 520 - </Show> 521 - </div> 522 - <Show when={showPlcLogs()}> 523 - <Show when={notice()}> 524 - <div>{notice()}</div> 525 - </Show> 526 - <PlcLogView plcOps={plcOps() ?? []} did={did} /> 527 - </Show> 528 - </div> 529 - )} 530 - </Show> 531 - </Show> 532 - </div> 533 - </Show> 534 - ); 535 - }; 536 - 537 - export { RepoView };
-48
src/views/settings.tsx
··· 1 - import { createSignal } from "solid-js"; 2 - import { TextInput } from "../components/text-input.jsx"; 3 - 4 - export const [hideMedia, setHideMedia] = createSignal(localStorage.hideMedia === "true"); 5 - 6 - const Settings = () => { 7 - return ( 8 - <div class="flex w-[22rem] flex-col gap-3 sm:w-[24rem]"> 9 - <div class="flex items-center gap-1 font-semibold"> 10 - <span>Settings</span> 11 - </div> 12 - <div class="flex flex-col gap-2"> 13 - <div class="flex flex-col gap-0.5"> 14 - <label for="plcDirectory" class="select-none"> 15 - PLC Directory 16 - </label> 17 - <TextInput 18 - id="plcDirectory" 19 - value={localStorage.plcDirectory || "https://plc.directory"} 20 - onInput={(e) => { 21 - e.currentTarget.value.length ? 22 - (localStorage.plcDirectory = e.currentTarget.value) 23 - : localStorage.removeItem("plcDirectory"); 24 - }} 25 - /> 26 - </div> 27 - <div class="flex justify-between"> 28 - <div class="flex items-center gap-1"> 29 - <input 30 - id="disableMedia" 31 - type="checkbox" 32 - checked={localStorage.hideMedia === "true"} 33 - onChange={(e) => { 34 - localStorage.hideMedia = e.currentTarget.checked; 35 - setHideMedia(e.currentTarget.checked); 36 - }} 37 - /> 38 - <label for="disableMedia" class="select-none"> 39 - Hide media embeds 40 - </label> 41 - </div> 42 - </div> 43 - </div> 44 - </div> 45 - ); 46 - }; 47 - 48 - export { Settings };
-262
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"; 5 - import { JSONValue } from "../components/json"; 6 - import { StickyOverlay } from "../components/sticky"; 7 - import { TextInput } from "../components/text-input"; 8 - 9 - const LIMIT = 25; 10 - type Parameter = { name: string; param: string | string[] | undefined }; 11 - 12 - const StreamView = () => { 13 - const [searchParams, setSearchParams] = useSearchParams(); 14 - const [parameters, setParameters] = createSignal<Parameter[]>([]); 15 - const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 16 - const [records, setRecords] = createSignal<Array<any>>([]); 17 - const [connected, setConnected] = createSignal(false); 18 - const [notice, setNotice] = createSignal(""); 19 - let socket: WebSocket; 20 - let firehose: Firehose; 21 - let formRef!: HTMLFormElement; 22 - 23 - const connectSocket = async (formData: FormData) => { 24 - setNotice(""); 25 - if (connected()) { 26 - if (streamType === "jetstream") socket?.close(); 27 - else firehose?.close(); 28 - setConnected(false); 29 - return; 30 - } 31 - setRecords([]); 32 - 33 - let url = ""; 34 - if (streamType === "jetstream") { 35 - url = 36 - formData.get("instance")?.toString() ?? "wss://jetstream1.us-east.bsky.network/subscribe"; 37 - url = url.concat("?"); 38 - } else { 39 - url = formData.get("instance")?.toString() ?? "wss://bsky.network"; 40 - } 41 - 42 - const collections = formData.get("collections")?.toString().split(","); 43 - collections?.forEach((collection) => { 44 - if (collection.length) url = url.concat(`wantedCollections=${collection}&`); 45 - }); 46 - 47 - const dids = formData.get("dids")?.toString().split(","); 48 - dids?.forEach((did) => { 49 - if (did.length) url = url.concat(`wantedDids=${did}&`); 50 - }); 51 - 52 - const cursor = formData.get("cursor")?.toString(); 53 - if (streamType === "jetstream") { 54 - if (cursor?.length) url = url.concat(`cursor=${cursor}`); 55 - if (url.endsWith("&")) url = url.slice(0, -1); 56 - } 57 - 58 - setSearchParams({ 59 - instance: formData.get("instance")?.toString(), 60 - collections: formData.get("collections")?.toString(), 61 - dids: formData.get("dids")?.toString(), 62 - cursor: formData.get("cursor")?.toString(), 63 - allEvents: formData.get("allEvents")?.toString(), 64 - }); 65 - 66 - setParameters([ 67 - { name: "Instance", param: formData.get("instance")?.toString() }, 68 - { name: "Collections", param: formData.get("collections")?.toString() }, 69 - { name: "DIDs", param: formData.get("dids")?.toString() }, 70 - { name: "Cursor", param: formData.get("cursor")?.toString() }, 71 - { name: "All Events", param: formData.get("allEvents")?.toString() }, 72 - ]); 73 - 74 - setConnected(true); 75 - if (streamType === "jetstream") { 76 - socket = new WebSocket(url); 77 - socket.addEventListener("message", (event) => { 78 - const rec = JSON.parse(event.data); 79 - if (searchParams.allEvents === "on" || (rec.kind !== "account" && rec.kind !== "identity")) 80 - setRecords(records().concat(rec).slice(-LIMIT)); 81 - }); 82 - socket.addEventListener("error", () => { 83 - setNotice("Connection error"); 84 - setConnected(false); 85 - }); 86 - } else { 87 - firehose = new Firehose({ 88 - relay: url, 89 - cursor: cursor, 90 - autoReconnect: false, 91 - }); 92 - firehose.on("error", (err) => { 93 - console.error(err); 94 - }); 95 - firehose.on("commit", (commit) => { 96 - for (const op of commit.ops) { 97 - const record = { 98 - $type: commit.$type, 99 - repo: commit.repo, 100 - seq: commit.seq, 101 - time: commit.time, 102 - rev: commit.rev, 103 - since: commit.since, 104 - op: op, 105 - }; 106 - setRecords(records().concat(record).slice(-LIMIT)); 107 - } 108 - }); 109 - firehose.on("identity", (identity) => { 110 - setRecords(records().concat(identity).slice(-LIMIT)); 111 - }); 112 - firehose.on("account", (account) => { 113 - setRecords(records().concat(account).slice(-LIMIT)); 114 - }); 115 - firehose.on("sync", (sync) => { 116 - const event = { 117 - $type: sync.$type, 118 - did: sync.did, 119 - rev: sync.rev, 120 - seq: sync.seq, 121 - time: sync.time, 122 - }; 123 - setRecords(records().concat(event).slice(-LIMIT)); 124 - }); 125 - firehose.start(); 126 - } 127 - }; 128 - 129 - onMount(async () => { 130 - const formData = new FormData(); 131 - if (searchParams.instance) formData.append("instance", searchParams.instance.toString()); 132 - if (searchParams.collections) 133 - formData.append("collections", searchParams.collections.toString()); 134 - if (searchParams.dids) formData.append("dids", searchParams.dids.toString()); 135 - if (searchParams.cursor) formData.append("cursor", searchParams.cursor.toString()); 136 - if (searchParams.allEvents) formData.append("allEvents", searchParams.allEvents.toString()); 137 - if (searchParams.instance) connectSocket(formData); 138 - }); 139 - 140 - onCleanup(() => socket?.close()); 141 - 142 - return ( 143 - <div class="flex flex-col items-center"> 144 - <div class="flex gap-2 text-sm"> 145 - <A 146 - class="flex items-center gap-1 border-b-2 p-1" 147 - inactiveClass="border-transparent hover:border-neutral-400 dark:hover:border-neutral-600" 148 - href="/jetstream" 149 - > 150 - <span class="iconify lucide--radio-tower"></span> 151 - Jetstream 152 - </A> 153 - <A 154 - class="flex items-center gap-1 border-b-2 p-1" 155 - inactiveClass="border-transparent hover:border-neutral-400 dark:hover:border-neutral-600" 156 - href="/firehose" 157 - > 158 - <span class="iconify lucide--waves"></span> 159 - Firehose 160 - </A> 161 - </div> 162 - <StickyOverlay> 163 - <form ref={formRef} class="flex w-[22rem] flex-col gap-1 text-sm sm:w-[24rem]"> 164 - <Show when={!connected()}> 165 - <label class="flex items-center justify-end gap-x-1"> 166 - <span class="min-w-[5rem]">Instance</span> 167 - <TextInput 168 - name="instance" 169 - value={ 170 - searchParams.instance ?? 171 - (streamType === "jetstream" ? 172 - "wss://jetstream1.us-east.bsky.network/subscribe" 173 - : "wss://bsky.network") 174 - } 175 - class="grow" 176 - /> 177 - </label> 178 - <Show when={streamType === "jetstream"}> 179 - <label class="flex items-center justify-end gap-x-1"> 180 - <span class="min-w-[5rem]">Collections</span> 181 - <textarea 182 - name="collections" 183 - spellcheck={false} 184 - placeholder="Comma-separated list of collections" 185 - value={searchParams.collections ?? ""} 186 - class="dark:bg-dark-100 dark:shadow-dark-800 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 shadow-xs focus:outline-[1.5px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200" 187 - /> 188 - </label> 189 - </Show> 190 - <Show when={streamType === "jetstream"}> 191 - <label class="flex items-center justify-end gap-x-1"> 192 - <span class="min-w-[5rem]">DIDs</span> 193 - <textarea 194 - name="dids" 195 - spellcheck={false} 196 - placeholder="Comma-separated list of DIDs" 197 - value={searchParams.dids ?? ""} 198 - class="dark:bg-dark-100 dark:shadow-dark-800 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 shadow-xs focus:outline-[1.5px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200" 199 - /> 200 - </label> 201 - </Show> 202 - <label class="flex items-center justify-end gap-x-1"> 203 - <span class="min-w-[5rem]">Cursor</span> 204 - <TextInput 205 - name="cursor" 206 - placeholder="Leave empty for live-tail" 207 - value={searchParams.cursor ?? ""} 208 - class="grow" 209 - /> 210 - </label> 211 - <Show when={streamType === "jetstream"}> 212 - <div class="flex items-center justify-end gap-x-1"> 213 - <input 214 - type="checkbox" 215 - name="allEvents" 216 - id="allEvents" 217 - checked={searchParams.allEvents === "on" ? true : false} 218 - /> 219 - <label for="allEvents" class="select-none"> 220 - Show account and identity events 221 - </label> 222 - </div> 223 - </Show> 224 - </Show> 225 - <Show when={connected()}> 226 - <div class="flex flex-col gap-1 wrap-anywhere"> 227 - <For each={parameters()}> 228 - {(param) => ( 229 - <Show when={param.param}> 230 - <div class="flex"> 231 - <div class="min-w-[6rem] font-semibold">{param.name}</div> 232 - {param.param} 233 - </div> 234 - </Show> 235 - )} 236 - </For> 237 - </div> 238 - </Show> 239 - <div class="flex justify-end"> 240 - <Button onClick={() => connectSocket(new FormData(formRef))}> 241 - {connected() ? "Disconnect" : "Connect"} 242 - </Button> 243 - </div> 244 - </form> 245 - </StickyOverlay> 246 - <Show when={notice().length}> 247 - <div class="text-red-500 dark:text-red-400">{notice()}</div> 248 - </Show> 249 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 px-4 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-[48rem]"> 250 - <For each={records().toReversed()}> 251 - {(rec) => ( 252 - <div class="pb-2"> 253 - <JSONValue data={rec} repo={rec.did ?? rec.repo} /> 254 - </div> 255 - )} 256 - </For> 257 - </div> 258 - </div> 259 - ); 260 - }; 261 - 262 - export { StreamView };