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