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