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