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