atproto explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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