atproto explorer pdsls.dev
atproto tool
1import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto"; 2import { Client, CredentialManager } from "@atcute/client"; 3import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons"; 4import * as TID from "@atcute/tid"; 5import { A, useParams } from "@solidjs/router"; 6import { 7 createEffect, 8 createMemo, 9 createResource, 10 createSignal, 11 For, 12 Show, 13 untrack, 14} from "solid-js"; 15import { createStore } from "solid-js/store"; 16import { Button } from "../components/button.jsx"; 17import { JSONType, JSONValue } from "../components/json.jsx"; 18import { agent } from "../components/login.jsx"; 19import { Modal } from "../components/modal.jsx"; 20import { addNotification, removeNotification } from "../components/notification.jsx"; 21import { StickyOverlay } from "../components/sticky.jsx"; 22import { TextInput } from "../components/text-input.jsx"; 23import Tooltip from "../components/tooltip.jsx"; 24import { resolvePDS } from "../utils/api.js"; 25import { localDateFromTimestamp } from "../utils/date.js"; 26 27interface AtprotoRecord { 28 rkey: string; 29 cid: string; 30 record: InferXRPCBodyOutput<ComAtprotoRepoGetRecord.mainSchema["output"]>; 31 timestamp: number | undefined; 32 toDelete: boolean; 33} 34 35const LIMIT = 100; 36 37const RecordLink = (props: { record: AtprotoRecord }) => { 38 const [hover, setHover] = createSignal(false); 39 const [previewHeight, setPreviewHeight] = createSignal(0); 40 let rkeyRef!: HTMLSpanElement; 41 let previewRef!: HTMLSpanElement; 42 43 createEffect(() => { 44 if (hover()) setPreviewHeight(previewRef.offsetHeight); 45 }); 46 47 const isOverflowing = (previewHeight: number) => 48 rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; 49 50 return ( 51 <span 52 class="relative flex w-full min-w-0 items-baseline rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 53 ref={rkeyRef} 54 onmouseover={() => setHover(true)} 55 onmouseleave={() => setHover(false)} 56 > 57 <span class="flex items-baseline truncate"> 58 <span class="shrink-0 text-sm text-blue-400 sm:text-base">{props.record.rkey}</span> 59 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 60 {props.record.cid} 61 </span> 62 <Show when={props.record.timestamp && props.record.timestamp <= Date.now()}> 63 <span class="ml-1 shrink-0 text-xs"> 64 {localDateFromTimestamp(props.record.timestamp!)} 65 </span> 66 </Show> 67 </span> 68 <Show when={hover()}> 69 <span 70 ref={previewRef} 71 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"}`} 72 > 73 <JSONValue 74 data={props.record.record.value as JSONType} 75 repo={props.record.record.uri.split("/")[2]} 76 /> 77 </span> 78 </Show> 79 </span> 80 ); 81}; 82 83const CollectionView = () => { 84 const params = useParams(); 85 const [cursor, setCursor] = createSignal<string>(); 86 const [records, setRecords] = createStore<AtprotoRecord[]>([]); 87 const [filter, setFilter] = createSignal<string>(); 88 const [batchDelete, setBatchDelete] = createSignal(false); 89 const [lastSelected, setLastSelected] = createSignal<number>(); 90 const [reverse, setReverse] = createSignal(false); 91 const [recreate, setRecreate] = createSignal(false); 92 const [openDelete, setOpenDelete] = createSignal(false); 93 const did = params.repo; 94 let pds: string; 95 let rpc: Client; 96 97 const fetchRecords = async () => { 98 if (!pds) pds = await resolvePDS(did!); 99 if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 100 const res = await rpc.get("com.atproto.repo.listRecords", { 101 params: { 102 repo: did as ActorIdentifier, 103 collection: params.collection as `${string}.${string}.${string}`, 104 limit: LIMIT, 105 cursor: cursor(), 106 reverse: reverse(), 107 }, 108 }); 109 if (!res.ok) throw new Error(res.data.error); 110 setCursor(res.data.records.length < LIMIT ? undefined : res.data.cursor); 111 const tmpRecords: AtprotoRecord[] = []; 112 res.data.records.forEach((record) => { 113 const rkey = record.uri.split("/").pop()!; 114 tmpRecords.push({ 115 rkey: rkey, 116 cid: record.cid, 117 record: record, 118 timestamp: TID.validate(rkey) ? TID.parse(rkey).timestamp / 1000 : undefined, 119 toDelete: false, 120 }); 121 }); 122 setRecords(records.concat(tmpRecords) ?? tmpRecords); 123 return res.data.records; 124 }; 125 126 const [response, { refetch }] = createResource(fetchRecords); 127 128 const filteredRecords = createMemo(() => 129 records.filter((rec) => 130 filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true, 131 ), 132 ); 133 134 const deleteRecords = async () => { 135 const recsToDel = records.filter((record) => record.toDelete); 136 let writes: Array< 137 | $type.enforce<ComAtprotoRepoApplyWrites.Delete> 138 | $type.enforce<ComAtprotoRepoApplyWrites.Create> 139 > = []; 140 recsToDel.forEach((record) => { 141 writes.push({ 142 $type: "com.atproto.repo.applyWrites#delete", 143 collection: params.collection as `${string}.${string}.${string}`, 144 rkey: record.rkey, 145 }); 146 if (recreate()) { 147 writes.push({ 148 $type: "com.atproto.repo.applyWrites#create", 149 collection: params.collection as `${string}.${string}.${string}`, 150 rkey: record.rkey, 151 value: record.record.value, 152 }); 153 } 154 }); 155 156 const BATCHSIZE = 200; 157 rpc = new Client({ handler: agent()! }); 158 for (let i = 0; i < writes.length; i += BATCHSIZE) { 159 await rpc.post("com.atproto.repo.applyWrites", { 160 input: { 161 repo: agent()!.sub, 162 writes: writes.slice(i, i + BATCHSIZE), 163 }, 164 }); 165 } 166 const id = addNotification({ 167 message: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, 168 type: "success", 169 }); 170 setTimeout(() => removeNotification(id), 3000); 171 setBatchDelete(false); 172 setRecords([]); 173 setCursor(undefined); 174 setOpenDelete(false); 175 setRecreate(false); 176 refetch(); 177 }; 178 179 const handleSelectionClick = (e: MouseEvent, index: number) => { 180 if (e.shiftKey && lastSelected() !== undefined) 181 setRecords( 182 { 183 from: lastSelected()! < index ? lastSelected() : index + 1, 184 to: index > lastSelected()! ? index - 1 : lastSelected(), 185 }, 186 "toDelete", 187 true, 188 ); 189 else setLastSelected(index); 190 }; 191 192 const selectAll = () => 193 setRecords( 194 records 195 .map((record, index) => 196 JSON.stringify(record.record.value).includes(filter() ?? "") ? index : undefined, 197 ) 198 .filter((i) => i !== undefined), 199 "toDelete", 200 true, 201 ); 202 203 return ( 204 <Show when={records.length || response()}> 205 <div class="-mt-2 flex w-full flex-col items-center"> 206 <StickyOverlay> 207 <div class="flex w-full flex-col gap-2"> 208 <div class="flex items-center gap-1"> 209 <Show when={agent() && agent()?.sub === did}> 210 <div class="flex items-center"> 211 <Tooltip 212 text={batchDelete() ? "Cancel" : "Delete"} 213 children={ 214 <button 215 onclick={() => { 216 setRecords( 217 { from: 0, to: untrack(() => records.length) - 1 }, 218 "toDelete", 219 false, 220 ); 221 setLastSelected(undefined); 222 setBatchDelete(!batchDelete()); 223 }} 224 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" 225 > 226 <span 227 class={`iconify text-lg ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `} 228 ></span> 229 </button> 230 } 231 /> 232 <Show when={batchDelete()}> 233 <Tooltip 234 text="Select all" 235 children={ 236 <button 237 onclick={() => selectAll()} 238 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" 239 > 240 <span class="iconify lucide--copy-check text-lg"></span> 241 </button> 242 } 243 /> 244 <Tooltip 245 text="Recreate" 246 children={ 247 <button 248 onclick={() => { 249 setRecreate(true); 250 setOpenDelete(true); 251 }} 252 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" 253 > 254 <span class="iconify lucide--recycle text-lg text-green-500 dark:text-green-400"></span> 255 </button> 256 } 257 /> 258 <Tooltip 259 text="Delete" 260 children={ 261 <button 262 onclick={() => { 263 setRecreate(false); 264 setOpenDelete(true); 265 }} 266 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" 267 > 268 <span class="iconify lucide--trash-2 text-lg text-red-500 dark:text-red-400"></span> 269 </button> 270 } 271 /> 272 </Show> 273 </div> 274 <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 275 <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"> 276 <h2 class="mb-2 font-semibold"> 277 {recreate() ? "Recreate" : "Delete"}{" "} 278 {records.filter((r) => r.toDelete).length} records? 279 </h2> 280 <div class="flex justify-end gap-2"> 281 <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 282 <Button 283 onClick={deleteRecords} 284 class={`dark:shadow-dark-700 rounded-lg px-2 py-1.5 text-xs text-white 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"}`} 285 > 286 {recreate() ? "Recreate" : "Delete"} 287 </Button> 288 </div> 289 </div> 290 </Modal> 291 </Show> 292 <Tooltip text="Jetstream"> 293 <A 294 href={`/jetstream?collections=${params.collection}&dids=${params.repo}`} 295 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" 296 > 297 <span class="iconify lucide--radio-tower text-lg"></span> 298 </A> 299 </Tooltip> 300 <TextInput 301 name="Filter" 302 placeholder="Filter by substring" 303 onInput={(e) => setFilter(e.currentTarget.value)} 304 class="grow" 305 /> 306 </div> 307 <Show when={records.length > 1}> 308 <div class="flex items-center justify-between gap-x-2"> 309 <Button 310 onClick={() => { 311 setReverse(!reverse()); 312 setRecords([]); 313 setCursor(undefined); 314 refetch(); 315 }} 316 > 317 <span 318 class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"}`} 319 ></span> 320 Reverse 321 </Button> 322 <div> 323 <Show when={batchDelete()}> 324 <span>{records.filter((rec) => rec.toDelete).length}</span> 325 <span>/</span> 326 </Show> 327 <span>{filter() ? filteredRecords().length : records.length} records</span> 328 </div> 329 <div class="flex w-20 items-center justify-end"> 330 <Show when={cursor()}> 331 <Show when={!response.loading}> 332 <Button onClick={() => refetch()}>Load More</Button> 333 </Show> 334 <Show when={response.loading}> 335 <div class="iconify lucide--loader-circle w-20 animate-spin text-xl" /> 336 </Show> 337 </Show> 338 </div> 339 </div> 340 </Show> 341 </div> 342 </StickyOverlay> 343 <div class="flex max-w-full flex-col px-2 font-mono"> 344 <For each={filteredRecords()}> 345 {(record, index) => ( 346 <> 347 <Show when={batchDelete()}> 348 <label 349 class="flex items-center gap-1 select-none" 350 onclick={(e) => handleSelectionClick(e, index())} 351 > 352 <input 353 type="checkbox" 354 checked={record.toDelete} 355 onchange={(e) => setRecords(index(), "toDelete", e.currentTarget.checked)} 356 /> 357 <RecordLink record={record} /> 358 </label> 359 </Show> 360 <Show when={!batchDelete()}> 361 <A href={`/at://${did}/${params.collection}/${record.rkey}`}> 362 <RecordLink record={record} /> 363 </A> 364 </Show> 365 </> 366 )} 367 </For> 368 </div> 369 </div> 370 </Show> 371 ); 372}; 373 374export { CollectionView };