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 MenuItem = (props: { icon: string; label: string; onClick: () => void }) => { 187 return ( 188 <button 189 type="button" 190 class="flex items-center gap-2 rounded-lg p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 191 onClick={props.onClick} 192 > 193 <span class={`iconify ${props.icon}`}></span> 194 <span>{props.label}</span> 195 </button> 196 ); 197 }; 198 199 const FileUpload = (props: { file: File }) => { 200 const [uploading, setUploading] = createSignal(false); 201 const [error, setError] = createSignal(""); 202 203 onCleanup(() => (blobInput.value = "")); 204 205 const formatFileSize = (bytes: number) => { 206 if (bytes === 0) return "0 Bytes"; 207 const k = 1024; 208 const sizes = ["Bytes", "KB", "MB", "GB"]; 209 const i = Math.floor(Math.log(bytes) / Math.log(k)); 210 return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 211 }; 212 213 const uploadBlob = async () => { 214 let blob: Blob; 215 216 const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 217 (document.getElementById("mimetype") as HTMLInputElement).value = ""; 218 if (mimetype) blob = new Blob([props.file], { type: mimetype }); 219 else blob = props.file; 220 221 if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 222 const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 223 if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); 224 } 225 226 const rpc = new Client({ handler: agent()! }); 227 setUploading(true); 228 const res = await rpc.post("com.atproto.repo.uploadBlob", { 229 input: blob, 230 }); 231 setUploading(false); 232 if (!res.ok) { 233 setError(res.data.error); 234 return; 235 } 236 editorView.dispatch({ 237 changes: { 238 from: editorView.state.selection.main.head, 239 insert: JSON.stringify(res.data.blob, null, 2), 240 }, 241 }); 242 setOpenUpload(false); 243 }; 244 245 return ( 246 <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"> 247 <h2 class="mb-2 font-semibold">Upload blob</h2> 248 <div class="flex flex-col gap-2 text-sm"> 249 <div class="flex flex-col gap-1"> 250 <p class="flex gap-1"> 251 <span class="truncate">{props.file.name}</span> 252 <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 253 ({formatFileSize(props.file.size)}) 254 </span> 255 </p> 256 </div> 257 <div class="flex items-center gap-x-2"> 258 <label for="mimetype" class="shrink-0 select-none"> 259 MIME type 260 </label> 261 <TextInput id="mimetype" placeholder={props.file.type} /> 262 </div> 263 <div class="flex items-center gap-1"> 264 <input id="exif-rm" type="checkbox" checked /> 265 <label for="exif-rm" class="select-none"> 266 Remove EXIF data 267 </label> 268 </div> 269 <p class="text-xs text-neutral-600 dark:text-neutral-400"> 270 Metadata will be pasted after the cursor 271 </p> 272 <Show when={error()}> 273 <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 274 </Show> 275 <div class="flex justify-between gap-2"> 276 <Button onClick={() => setOpenUpload(false)}>Cancel</Button> 277 <Show when={uploading()}> 278 <div class="flex items-center gap-1"> 279 <span class="iconify lucide--loader-circle animate-spin"></span> 280 <span>Uploading</span> 281 </div> 282 </Show> 283 <Show when={!uploading()}> 284 <Button 285 onClick={uploadBlob} 286 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" 287 > 288 Upload 289 </Button> 290 </Show> 291 </div> 292 </div> 293 </div> 294 ); 295 }; 296 297 return ( 298 <> 299 <Modal 300 open={openDialog()} 301 onClose={() => setOpenDialog(false)} 302 closeOnClick={false} 303 nonBlocking={isMinimized()} 304 > 305 <div 306 classList={{ 307 "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, 308 "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(), 309 "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(), 310 hidden: isMinimized(), 311 }} 312 > 313 <div class="mb-2 flex w-full justify-between text-base"> 314 <div class="flex items-center gap-2"> 315 <span class="font-semibold select-none"> 316 {props.create ? "Creating" : "Editing"} record 317 </span> 318 </div> 319 <div class="flex items-center gap-1"> 320 <button 321 type="button" 322 onclick={() => setIsMinimized(true)} 323 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" 324 > 325 <span class="iconify lucide--minus"></span> 326 </button> 327 <button 328 type="button" 329 onclick={() => setIsMaximized(!isMaximized())} 330 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" 331 > 332 <span 333 class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 334 ></span> 335 </button> 336 <button 337 id="close" 338 onclick={() => setOpenDialog(false)} 339 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" 340 > 341 <span class="iconify lucide--x"></span> 342 </button> 343 </div> 344 </div> 345 <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 346 <Show when={props.create}> 347 <div class="flex flex-wrap items-center gap-1 text-sm"> 348 <span>at://</span> 349 <select 350 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" 351 name="repo" 352 id="repo" 353 > 354 <For each={Object.keys(sessions)}> 355 {(session) => ( 356 <option value={session} selected={session === agent()?.sub}> 357 {sessions[session].handle ?? session} 358 </option> 359 )} 360 </For> 361 </select> 362 <span>/</span> 363 <TextInput 364 id="collection" 365 name="collection" 366 placeholder="Collection (default: $type)" 367 class="w-40 placeholder:text-xs lg:w-52" 368 /> 369 <span>/</span> 370 <TextInput 371 id="rkey" 372 name="rkey" 373 placeholder="Record key (default: TID)" 374 class="w-40 placeholder:text-xs lg:w-52" 375 /> 376 </div> 377 </Show> 378 <div class="min-h-0 flex-1"> 379 <Editor 380 content={JSON.stringify( 381 !props.create ? props.record 382 : params.rkey ? placeholder() 383 : defaultPlaceholder(), 384 null, 385 2, 386 )} 387 /> 388 </div> 389 <div class="flex flex-col gap-2"> 390 <Show when={notice()}> 391 <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 392 </Show> 393 <div class="flex justify-between gap-2"> 394 <div class="relative" ref={insertMenuRef}> 395 <button 396 type="button" 397 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" 398 onClick={() => setOpenInsertMenu(!openInsertMenu())} 399 > 400 <span class="iconify lucide--plus select-none"></span> 401 </button> 402 <Show when={openInsertMenu()}> 403 <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"> 404 <MenuItem 405 icon="lucide--upload" 406 label="Upload blob" 407 onClick={() => { 408 setOpenInsertMenu(false); 409 blobInput.click(); 410 }} 411 /> 412 <MenuItem 413 icon="lucide--clock" 414 label="Insert timestamp" 415 onClick={insertTimestamp} 416 /> 417 </div> 418 </Show> 419 <input 420 type="file" 421 id="blob" 422 class="sr-only" 423 ref={blobInput} 424 onChange={(e) => { 425 if (e.target.files !== null) setOpenUpload(true); 426 }} 427 /> 428 </div> 429 <Modal 430 open={openUpload()} 431 onClose={() => setOpenUpload(false)} 432 closeOnClick={false} 433 > 434 <FileUpload file={blobInput.files![0]} /> 435 </Modal> 436 <div class="flex items-center justify-end gap-2"> 437 <button 438 type="button" 439 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" 440 onClick={() => 441 setValidate( 442 validate() === true ? false 443 : validate() === false ? undefined 444 : true, 445 ) 446 } 447 > 448 <Tooltip text={getValidateLabel()}> 449 <span class={`iconify ${getValidateIcon()}`}></span> 450 </Tooltip> 451 <span>Validate</span> 452 </button> 453 <Show when={!props.create}> 454 <Button onClick={() => editRecord(true)}>Recreate</Button> 455 </Show> 456 <Button 457 onClick={() => 458 props.create ? createRecord(new FormData(formRef)) : editRecord() 459 } 460 > 461 {props.create ? "Create" : "Edit"} 462 </Button> 463 </div> 464 </div> 465 </div> 466 </form> 467 </div> 468 </Modal> 469 <Show when={isMinimized() && openDialog()}> 470 <button 471 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" 472 onclick={() => setIsMinimized(false)} 473 > 474 <span class="iconify lucide--square-pen text-lg"></span> 475 <span class="text-sm font-medium">{props.create ? "Creating" : "Editing"} record</span> 476 </button> 477 </Show> 478 <Tooltip text={`${props.create ? "Create" : "Edit"} record`}> 479 <button 480 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"}`} 481 onclick={() => { 482 setNotice(""); 483 setOpenDialog(true); 484 setIsMinimized(false); 485 }} 486 > 487 <div 488 class={props.create ? "iconify lucide--square-pen text-lg" : "iconify lucide--pencil"} 489 /> 490 </button> 491 </Tooltip> 492 </> 493 ); 494};