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