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