atproto explorer pdsls.dev
atproto tool
1import { Client } from "@atcute/client"; 2import { Did } from "@atcute/lexicons"; 3import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 4import { remove } from "@mary/exif-rm"; 5import { useNavigate, useParams } from "@solidjs/router"; 6import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 7import { Editor, editorView } from "../components/editor.jsx"; 8import { agent } from "../components/login.jsx"; 9import { sessions } from "./account.jsx"; 10import { Button } from "./button.jsx"; 11import { Modal } from "./modal.jsx"; 12import { addNotification, removeNotification } from "./notification.jsx"; 13import { TextInput } from "./text-input.jsx"; 14import Tooltip from "./tooltip.jsx"; 15 16export const [placeholder, setPlaceholder] = createSignal<any>(); 17 18export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 19 const navigate = useNavigate(); 20 const params = useParams(); 21 const [openDialog, setOpenDialog] = createSignal(false); 22 const [notice, setNotice] = createSignal(""); 23 const [openUpload, setOpenUpload] = createSignal(false); 24 const [validate, setValidate] = createSignal<boolean | undefined>(undefined); 25 const [nonBlocking, setNonBlocking] = createSignal(false); 26 let blobInput!: HTMLInputElement; 27 let formRef!: HTMLFormElement; 28 29 const defaultPlaceholder = () => { 30 return { 31 $type: "app.bsky.feed.post", 32 text: "This post was sent from PDSls", 33 embed: { 34 $type: "app.bsky.embed.external", 35 external: { 36 uri: "https://pdsls.dev", 37 title: "PDSls", 38 description: "Browse the public data on atproto", 39 }, 40 }, 41 langs: ["en"], 42 createdAt: new Date().toISOString(), 43 }; 44 }; 45 46 const getValidateIcon = () => { 47 return ( 48 validate() === true ? "lucide--circle-check" 49 : validate() === false ? "lucide--circle-x" 50 : "lucide--circle" 51 ); 52 }; 53 54 const getValidateLabel = () => { 55 return ( 56 validate() === true ? "True" 57 : validate() === false ? "False" 58 : "Unset" 59 ); 60 }; 61 62 createEffect(() => { 63 if (openDialog()) { 64 setValidate(undefined); 65 setNonBlocking(false); 66 } 67 }); 68 69 const createRecord = async (formData: FormData) => { 70 const repo = formData.get("repo")?.toString(); 71 if (!repo) return; 72 const rpc = new Client({ handler: new OAuthUserAgent(await getSession(repo as Did)) }); 73 const collection = formData.get("collection"); 74 const rkey = formData.get("rkey"); 75 let record: any; 76 try { 77 record = JSON.parse(editorView.state.doc.toString()); 78 } catch (e: any) { 79 setNotice(e.message); 80 return; 81 } 82 const res = await rpc.post("com.atproto.repo.createRecord", { 83 input: { 84 repo: repo as Did, 85 collection: collection ? collection.toString() : record.$type, 86 rkey: rkey?.toString().length ? rkey?.toString() : undefined, 87 record: record, 88 validate: validate(), 89 }, 90 }); 91 if (!res.ok) { 92 setNotice(`${res.data.error}: ${res.data.message}`); 93 return; 94 } 95 setOpenDialog(false); 96 const id = addNotification({ 97 message: "Record created", 98 type: "success", 99 }); 100 setTimeout(() => removeNotification(id), 3000); 101 navigate(`/${res.data.uri}`); 102 }; 103 104 const editRecord = async (recreate?: boolean) => { 105 const record = editorView.state.doc.toString(); 106 if (!record) return; 107 const rpc = new Client({ handler: agent()! }); 108 try { 109 const editedRecord = JSON.parse(record); 110 if (recreate) { 111 const res = await rpc.post("com.atproto.repo.applyWrites", { 112 input: { 113 repo: agent()!.sub, 114 validate: validate(), 115 writes: [ 116 { 117 collection: params.collection as `${string}.${string}.${string}`, 118 rkey: params.rkey, 119 $type: "com.atproto.repo.applyWrites#delete", 120 }, 121 { 122 collection: params.collection as `${string}.${string}.${string}`, 123 rkey: params.rkey, 124 $type: "com.atproto.repo.applyWrites#create", 125 value: editedRecord, 126 }, 127 ], 128 }, 129 }); 130 if (!res.ok) { 131 setNotice(`${res.data.error}: ${res.data.message}`); 132 return; 133 } 134 } else { 135 const res = await rpc.post("com.atproto.repo.putRecord", { 136 input: { 137 repo: agent()!.sub, 138 collection: params.collection as `${string}.${string}.${string}`, 139 rkey: params.rkey, 140 record: editedRecord, 141 validate: validate(), 142 }, 143 }); 144 if (!res.ok) { 145 setNotice(`${res.data.error}: ${res.data.message}`); 146 return; 147 } 148 } 149 setOpenDialog(false); 150 const id = addNotification({ 151 message: "Record edited", 152 type: "success", 153 }); 154 setTimeout(() => removeNotification(id), 3000); 155 props.refetch(); 156 } catch (err: any) { 157 setNotice(err.message); 158 } 159 }; 160 161 const dragBox = (box: HTMLDivElement) => { 162 let isDragging = false; 163 let offsetX: number; 164 let offsetY: number; 165 166 const handleMouseDown = (e: MouseEvent) => { 167 if (!(e.target instanceof HTMLElement)) return; 168 169 const closestDraggable = e.target.closest("[data-draggable]") as HTMLElement; 170 if (closestDraggable && closestDraggable !== box) return; 171 172 if ( 173 ["INPUT", "SELECT", "BUTTON", "LABEL"].includes(e.target.tagName) || 174 e.target.closest("#editor, #close") 175 ) 176 return; 177 178 e.preventDefault(); 179 isDragging = true; 180 box.classList.add("cursor-grabbing"); 181 182 const rect = box.getBoundingClientRect(); 183 184 box.style.left = rect.left + "px"; 185 box.style.top = rect.top + "px"; 186 187 box.classList.remove("-translate-x-1/2"); 188 189 offsetX = e.clientX - rect.left; 190 offsetY = e.clientY - rect.top; 191 }; 192 193 const handleMouseMove = (e: MouseEvent) => { 194 if (isDragging) { 195 let newLeft = e.clientX - offsetX; 196 let newTop = e.clientY - offsetY; 197 198 const boxWidth = box.offsetWidth; 199 const boxHeight = box.offsetHeight; 200 201 const viewportWidth = window.innerWidth; 202 const viewportHeight = window.innerHeight; 203 204 newLeft = Math.max(0, Math.min(newLeft, viewportWidth - boxWidth)); 205 newTop = Math.max(0, Math.min(newTop, viewportHeight - boxHeight)); 206 207 box.style.left = newLeft + "px"; 208 box.style.top = newTop + "px"; 209 } 210 }; 211 212 const handleMouseUp = () => { 213 if (isDragging) { 214 isDragging = false; 215 box.classList.remove("cursor-grabbing"); 216 } 217 }; 218 219 onMount(() => { 220 box.addEventListener("mousedown", handleMouseDown); 221 document.addEventListener("mousemove", handleMouseMove); 222 document.addEventListener("mouseup", handleMouseUp); 223 }); 224 225 onCleanup(() => { 226 box.removeEventListener("mousedown", handleMouseDown); 227 document.removeEventListener("mousemove", handleMouseMove); 228 document.removeEventListener("mouseup", handleMouseUp); 229 }); 230 }; 231 232 const FileUpload = (props: { file: File }) => { 233 const [uploading, setUploading] = createSignal(false); 234 const [error, setError] = createSignal(""); 235 236 onCleanup(() => (blobInput.value = "")); 237 238 const formatFileSize = (bytes: number) => { 239 if (bytes === 0) return "0 Bytes"; 240 const k = 1024; 241 const sizes = ["Bytes", "KB", "MB", "GB"]; 242 const i = Math.floor(Math.log(bytes) / Math.log(k)); 243 return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 244 }; 245 246 const uploadBlob = async () => { 247 let blob: Blob; 248 249 const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 250 (document.getElementById("mimetype") as HTMLInputElement).value = ""; 251 if (mimetype) blob = new Blob([props.file], { type: mimetype }); 252 else blob = props.file; 253 254 if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 255 const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 256 if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); 257 } 258 259 const rpc = new Client({ handler: agent()! }); 260 setUploading(true); 261 const res = await rpc.post("com.atproto.repo.uploadBlob", { 262 input: blob, 263 }); 264 setUploading(false); 265 if (!res.ok) { 266 setError(res.data.error); 267 return; 268 } 269 editorView.dispatch({ 270 changes: { 271 from: editorView.state.selection.main.head, 272 insert: JSON.stringify(res.data.blob, null, 2), 273 }, 274 }); 275 setOpenUpload(false); 276 }; 277 278 return ( 279 <div 280 data-draggable 281 class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-[20rem] -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" 282 ref={dragBox} 283 > 284 <h2 class="mb-2 font-semibold">Upload blob</h2> 285 <div class="flex flex-col gap-2 text-sm"> 286 <div class="flex flex-col gap-1"> 287 <p class="flex gap-1"> 288 <span class="truncate">{props.file.name}</span> 289 <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 290 ({formatFileSize(props.file.size)}) 291 </span> 292 </p> 293 </div> 294 <div class="flex items-center gap-x-2"> 295 <label for="mimetype" class="shrink-0 select-none"> 296 MIME type 297 </label> 298 <TextInput id="mimetype" placeholder={props.file.type} /> 299 </div> 300 <div class="flex items-center gap-1"> 301 <input id="exif-rm" type="checkbox" checked /> 302 <label for="exif-rm" class="select-none"> 303 Remove EXIF data 304 </label> 305 </div> 306 <p class="text-xs text-neutral-600 dark:text-neutral-400"> 307 Metadata will be pasted after the cursor 308 </p> 309 <Show when={error()}> 310 <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 311 </Show> 312 <div class="flex justify-between gap-2"> 313 <Button onClick={() => setOpenUpload(false)}>Cancel</Button> 314 <Show when={uploading()}> 315 <div class="flex items-center gap-1"> 316 <span class="iconify lucide--loader-circle animate-spin"></span> 317 <span>Uploading</span> 318 </div> 319 </Show> 320 <Show when={!uploading()}> 321 <Button 322 onClick={uploadBlob} 323 class="dark:shadow-dark-700 flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400" 324 > 325 Upload 326 </Button> 327 </Show> 328 </div> 329 </div> 330 </div> 331 ); 332 }; 333 334 return ( 335 <> 336 <Modal 337 open={openDialog()} 338 onClose={() => setOpenDialog(false)} 339 closeOnClick={false} 340 nonBlocking={nonBlocking()} 341 > 342 <div 343 data-draggable 344 classList={{ 345 "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto absolute top-18 left-[50%] w-screen -translate-x-1/2 cursor-grab rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 sm:w-xl lg:w-3xl dark:border-neutral-700 starting:opacity-0": true, 346 "opacity-60 hover:opacity-100": nonBlocking(), 347 }} 348 ref={dragBox} 349 > 350 <div class="mb-2 flex w-full justify-between text-base"> 351 <div class="flex items-center gap-2"> 352 <span class="font-semibold select-none"> 353 {props.create ? "Creating" : "Editing"} record 354 </span> 355 <Tooltip text={nonBlocking() ? "Lock" : "Unlock"}> 356 <button 357 type="button" 358 onclick={() => setNonBlocking(!nonBlocking())} 359 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" 360 > 361 <span 362 class={`iconify ${nonBlocking() ? "lucide--lock-open" : "lucide--lock"}`} 363 ></span> 364 </button> 365 </Tooltip> 366 </div> 367 <button 368 id="close" 369 onclick={() => setOpenDialog(false)} 370 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 371 > 372 <span class="iconify lucide--x"></span> 373 </button> 374 </div> 375 <form ref={formRef} class="flex flex-col gap-y-2"> 376 <Show when={props.create}> 377 <div class="flex flex-wrap items-center gap-1 text-sm"> 378 <span>at://</span> 379 <select 380 class="dark:bg-dark-100 dark:shadow-dark-700 max-w-40 truncate rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 shadow-xs select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 381 name="repo" 382 id="repo" 383 > 384 <For each={Object.keys(sessions)}> 385 {(session) => ( 386 <option value={session} selected={session === agent()?.sub}> 387 {sessions[session].handle ?? session} 388 </option> 389 )} 390 </For> 391 </select> 392 <span>/</span> 393 <TextInput 394 id="collection" 395 name="collection" 396 placeholder="Collection (default: $type)" 397 class="w-40 placeholder:text-xs lg:w-52" 398 /> 399 <span>/</span> 400 <TextInput 401 id="rkey" 402 name="rkey" 403 placeholder="Record key (default: TID)" 404 class="w-40 placeholder:text-xs lg:w-52" 405 /> 406 </div> 407 </Show> 408 <Editor 409 content={JSON.stringify( 410 !props.create ? props.record 411 : params.rkey ? placeholder() 412 : defaultPlaceholder(), 413 null, 414 2, 415 )} 416 /> 417 <div class="flex flex-col gap-2"> 418 <Show when={notice()}> 419 <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 420 </Show> 421 <div class="flex justify-between gap-2"> 422 <button 423 type="button" 424 class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 flex w-fit rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 text-xs shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 425 > 426 <input 427 type="file" 428 id="blob" 429 class="sr-only" 430 ref={blobInput} 431 onChange={(e) => { 432 if (e.target.files !== null) setOpenUpload(true); 433 }} 434 /> 435 <label class="flex items-center gap-1 px-2 py-1.5 select-none" for="blob"> 436 <span class="iconify lucide--upload"></span> 437 Upload 438 </label> 439 </button> 440 <Modal 441 open={openUpload()} 442 onClose={() => setOpenUpload(false)} 443 closeOnClick={false} 444 > 445 <FileUpload file={blobInput.files![0]} /> 446 </Modal> 447 <div class="flex items-center justify-end gap-2"> 448 <button 449 type="button" 450 class="flex items-center gap-1 rounded-sm p-1 text-sm hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 451 onClick={() => 452 setValidate( 453 validate() === true ? false 454 : validate() === false ? undefined 455 : true, 456 ) 457 } 458 > 459 <Tooltip text={getValidateLabel()}> 460 <span class={`iconify ${getValidateIcon()}`}></span> 461 </Tooltip> 462 <span>Validate</span> 463 </button> 464 <Show when={!props.create}> 465 <Button onClick={() => editRecord(true)}>Recreate</Button> 466 </Show> 467 <Button 468 onClick={() => 469 props.create ? createRecord(new FormData(formRef)) : editRecord() 470 } 471 > 472 {props.create ? "Create" : "Edit"} 473 </Button> 474 </div> 475 </div> 476 </div> 477 </form> 478 </div> 479 </Modal> 480 <Tooltip text={`${props.create ? "Create" : "Edit"} record`}> 481 <button 482 class={`flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`} 483 onclick={() => { 484 setNotice(""); 485 setOpenDialog(true); 486 }} 487 > 488 <div 489 class={props.create ? "iconify lucide--square-pen text-lg" : "iconify lucide--pencil"} 490 /> 491 </button> 492 </Tooltip> 493 </> 494 ); 495};