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