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