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