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