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