forked from pdsls.dev/pdsls
atproto explorer
at main 14 kB view raw
1import { Client } from "@atcute/client"; 2import { remove } from "@mary/exif-rm"; 3import { useNavigate, useParams } from "@solidjs/router"; 4import { createSignal, onCleanup, Show } from "solid-js"; 5import { Editor, editorView } from "../components/editor.jsx"; 6import { agent } from "../components/login.jsx"; 7import { setNotif } from "../layout.jsx"; 8import { Button } from "./button.jsx"; 9import { Modal } from "./modal.jsx"; 10import { TextInput } from "./text-input.jsx"; 11import Tooltip from "./tooltip.jsx"; 12 13export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 14 const navigate = useNavigate(); 15 const params = useParams(); 16 const [openDialog, setOpenDialog] = createSignal(false); 17 const [notice, setNotice] = createSignal(""); 18 const [openUpload, setOpenUpload] = createSignal(false); 19 let blobInput!: HTMLInputElement; 20 let formRef!: HTMLFormElement; 21 22 const placeholder = () => { 23 return { 24 $type: "app.bsky.feed.post", 25 text: "This post was sent from PDSls", 26 embed: { 27 $type: "app.bsky.embed.external", 28 external: { 29 uri: "https://pdsls.dev", 30 title: "PDSls", 31 description: "Browse the public data on atproto", 32 }, 33 }, 34 langs: ["en"], 35 createdAt: new Date().toISOString(), 36 }; 37 }; 38 39 const createRecord = async (formData: FormData) => { 40 const rpc = new Client({ handler: agent()! }); 41 const collection = formData.get("collection"); 42 const rkey = formData.get("rkey"); 43 const validate = formData.get("validate")?.toString(); 44 let record: any; 45 try { 46 record = JSON.parse(editorView.state.doc.toString()); 47 } catch (e: any) { 48 setNotice(e.message); 49 return; 50 } 51 const res = await rpc.post("com.atproto.repo.createRecord", { 52 input: { 53 repo: agent()!.sub, 54 collection: collection ? collection.toString() : record.$type, 55 rkey: rkey?.toString().length ? rkey?.toString() : undefined, 56 record: record, 57 validate: 58 validate === "true" ? true 59 : validate === "false" ? false 60 : undefined, 61 }, 62 }); 63 if (!res.ok) { 64 setNotice(`${res.data.error}: ${res.data.message}`); 65 return; 66 } 67 setOpenDialog(false); 68 setNotif({ show: true, icon: "lucide--file-check", text: "Record created" }); 69 navigate(`/${res.data.uri}`); 70 }; 71 72 const editRecord = async (formData: FormData) => { 73 const record = editorView.state.doc.toString(); 74 const validate = 75 formData.get("validate")?.toString() === "true" ? true 76 : formData.get("validate")?.toString() === "false" ? false 77 : undefined; 78 if (!record) return; 79 const rpc = new Client({ handler: agent()! }); 80 try { 81 const editedRecord = JSON.parse(record); 82 if (formData.get("recreate")) { 83 const res = await rpc.post("com.atproto.repo.applyWrites", { 84 input: { 85 repo: agent()!.sub, 86 validate: validate, 87 writes: [ 88 { 89 collection: params.collection as `${string}.${string}.${string}`, 90 rkey: params.rkey, 91 $type: "com.atproto.repo.applyWrites#delete", 92 }, 93 { 94 collection: params.collection as `${string}.${string}.${string}`, 95 rkey: params.rkey, 96 $type: "com.atproto.repo.applyWrites#create", 97 value: editedRecord, 98 }, 99 ], 100 }, 101 }); 102 if (!res.ok) { 103 setNotice(`${res.data.error}: ${res.data.message}`); 104 return; 105 } 106 } else { 107 const res = await rpc.post("com.atproto.repo.putRecord", { 108 input: { 109 repo: agent()!.sub, 110 collection: params.collection as `${string}.${string}.${string}`, 111 rkey: params.rkey, 112 record: editedRecord, 113 validate: validate, 114 }, 115 }); 116 if (!res.ok) { 117 setNotice(`${res.data.error}: ${res.data.message}`); 118 return; 119 } 120 } 121 setOpenDialog(false); 122 setNotif({ show: true, icon: "lucide--file-check", text: "Record edited" }); 123 props.refetch(); 124 } catch (err: any) { 125 setNotice(err.message); 126 } 127 }; 128 129 const FileUpload = (props: { file: File }) => { 130 const [uploading, setUploading] = createSignal(false); 131 const [error, setError] = createSignal(""); 132 133 onCleanup(() => (blobInput.value = "")); 134 135 const formatFileSize = (bytes: number) => { 136 if (bytes === 0) return "0 Bytes"; 137 const k = 1024; 138 const sizes = ["Bytes", "KB", "MB", "GB"]; 139 const i = Math.floor(Math.log(bytes) / Math.log(k)); 140 return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 141 }; 142 143 const uploadBlob = async () => { 144 let blob: Blob; 145 146 const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 147 (document.getElementById("mimetype") as HTMLInputElement).value = ""; 148 if (mimetype) blob = new Blob([props.file], { type: mimetype }); 149 else blob = props.file; 150 151 if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 152 const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 153 if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); 154 } 155 156 const rpc = new Client({ handler: agent()! }); 157 setUploading(true); 158 const res = await rpc.post("com.atproto.repo.uploadBlob", { 159 input: blob, 160 }); 161 setUploading(false); 162 if (!res.ok) { 163 setError(res.data.error); 164 return; 165 } 166 editorView.dispatch({ 167 changes: { 168 from: editorView.state.selection.main.head, 169 insert: JSON.stringify(res.data.blob, null, 2), 170 }, 171 }); 172 setOpenUpload(false); 173 }; 174 175 return ( 176 <div class="dark:bg-dark-300 dark:shadow-dark-800 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"> 177 <h2 class="mb-2 font-semibold">Upload blob</h2> 178 <div class="flex flex-col gap-2 text-sm"> 179 <div class="flex flex-col gap-1"> 180 <p class="flex gap-1"> 181 <span class="truncate">{props.file.name}</span> 182 <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 183 ({formatFileSize(props.file.size)}) 184 </span> 185 </p> 186 </div> 187 <div class="flex items-center gap-x-2"> 188 <label for="mimetype" class="shrink-0 select-none"> 189 MIME type 190 </label> 191 <TextInput id="mimetype" placeholder={props.file.type} /> 192 </div> 193 <div class="flex items-center gap-1"> 194 <input id="exif-rm" type="checkbox" checked /> 195 <label for="exif-rm" class="select-none"> 196 Remove EXIF data 197 </label> 198 </div> 199 <p class="text-xs text-neutral-600 dark:text-neutral-400"> 200 Metadata will be pasted after the cursor 201 </p> 202 <Show when={error()}> 203 <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 204 </Show> 205 <div class="flex justify-between gap-2"> 206 <Button onClick={() => setOpenUpload(false)}>Cancel</Button> 207 <Show when={uploading()}> 208 <div class="flex items-center gap-1"> 209 <span class="iconify lucide--loader-circle animate-spin"></span> 210 <span>Uploading</span> 211 </div> 212 </Show> 213 <Show when={!uploading()}> 214 <Button 215 onClick={uploadBlob} 216 class="dark:shadow-dark-800 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" 217 > 218 Upload 219 </Button> 220 </Show> 221 </div> 222 </div> 223 </div> 224 ); 225 }; 226 227 return ( 228 <> 229 <Modal open={openDialog()} onClose={() => setOpenDialog(false)} closeOnClick={false}> 230 <div class="dark:bg-dark-300 dark:shadow-dark-800 absolute top-16 left-[50%] w-screen -translate-x-1/2 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"> 231 <div class="mb-2 flex w-full justify-between"> 232 <div class="flex items-center gap-1 font-semibold"> 233 <span 234 class={`iconify ${props.create ? "lucide--square-pen" : "lucide--pencil"}`} 235 ></span> 236 <span>{props.create ? "Creating" : "Editing"} record</span> 237 </div> 238 <button 239 onclick={() => setOpenDialog(false)} 240 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" 241 > 242 <span class="iconify lucide--x"></span> 243 </button> 244 </div> 245 <form ref={formRef} class="flex flex-col gap-y-2"> 246 <div class="flex w-fit flex-col gap-y-1 text-sm"> 247 <Show when={props.create}> 248 <div class="flex items-center gap-x-2"> 249 <label for="collection" class="min-w-20 select-none"> 250 Collection 251 </label> 252 <TextInput 253 id="collection" 254 name="collection" 255 placeholder="Optional (default: $type)" 256 class="w-[15rem]" 257 /> 258 </div> 259 <div class="flex items-center gap-x-2"> 260 <label for="rkey" class="min-w-20 select-none"> 261 Record key 262 </label> 263 <TextInput 264 id="rkey" 265 name="rkey" 266 placeholder="Optional (default: TID)" 267 class="w-[15rem]" 268 /> 269 </div> 270 </Show> 271 <div class="flex items-center gap-x-2"> 272 <label for="validate" class="min-w-20 select-none"> 273 Validate 274 </label> 275 <select 276 name="validate" 277 id="validate" 278 class="dark:bg-dark-100 dark:shadow-dark-800 rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 shadow-xs focus:outline-[1px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200" 279 > 280 <option value="unset">Unset</option> 281 <option value="true">True</option> 282 <option value="false">False</option> 283 </select> 284 </div> 285 </div> 286 <Editor 287 content={JSON.stringify(props.create ? placeholder() : props.record, null, 2)} 288 /> 289 <div class="flex flex-col gap-2"> 290 <Show when={notice()}> 291 <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 292 </Show> 293 <div class="flex justify-between gap-2"> 294 <div class="dark:hover:bg-dark-200 dark:shadow-dark-800 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"> 295 <input 296 type="file" 297 id="blob" 298 class="sr-only" 299 ref={blobInput} 300 onChange={(e) => { 301 if (e.target.files !== null) setOpenUpload(true); 302 }} 303 /> 304 <label class="flex items-center gap-1 px-2 py-1.5 select-none" for="blob"> 305 <span class="iconify lucide--upload"></span> 306 Upload 307 </label> 308 </div> 309 <Modal 310 open={openUpload()} 311 onClose={() => setOpenUpload(false)} 312 closeOnClick={false} 313 > 314 <FileUpload file={blobInput.files![0]} /> 315 </Modal> 316 <div class="flex items-center justify-end gap-2"> 317 <Show when={!props.create}> 318 <div class="flex items-center gap-1"> 319 <input id="recreate" name="recreate" type="checkbox" /> 320 <label for="recreate" class="text-sm select-none"> 321 Recreate record 322 </label> 323 </div> 324 </Show> 325 <Button 326 onClick={() => 327 props.create ? 328 createRecord(new FormData(formRef)) 329 : editRecord(new FormData(formRef)) 330 } 331 > 332 {props.create ? "Create" : "Edit"} 333 </Button> 334 </div> 335 </div> 336 </div> 337 </form> 338 </div> 339 </Modal> 340 <Tooltip text={`${props.create ? "Create" : "Edit"} record`}> 341 <button 342 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"}`} 343 onclick={() => { 344 setNotice(""); 345 setOpenDialog(true); 346 }} 347 > 348 <div 349 class={props.create ? "iconify lucide--square-pen text-xl" : "iconify lucide--pencil"} 350 /> 351 </button> 352 </Tooltip> 353 </> 354 ); 355};