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