atproto explorer
0
fork

Configure Feed

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

at 9671d6fe3ba2fa45e5149cfca9e98b08419fcbd3 298 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 176 onclick={() => setOpenDialog(false)} 177 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" 178 > 179 <span class="iconify lucide--x"></span> 180 </button> 181 </div> 182 <form ref={formRef} class="flex flex-col gap-y-2"> 183 <div class="flex w-fit flex-col gap-y-1 text-xs sm:text-sm"> 184 <Show when={props.create}> 185 <div class="flex items-center gap-x-2"> 186 <label for="collection" class="min-w-20 select-none"> 187 Collection 188 </label> 189 <TextInput 190 id="collection" 191 name="collection" 192 placeholder="Optional (default: record type)" 193 class="w-[15rem]" 194 /> 195 </div> 196 <div class="flex items-center gap-x-2"> 197 <label for="rkey" class="min-w-20 select-none"> 198 Record key 199 </label> 200 <TextInput 201 id="rkey" 202 name="rkey" 203 placeholder="Optional (default: TID)" 204 class="w-[15rem]" 205 /> 206 </div> 207 </Show> 208 <div class="flex items-center gap-x-2"> 209 <label for="validate" class="min-w-20 select-none"> 210 Validate 211 </label> 212 <select 213 name="validate" 214 id="validate" 215 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" 216 > 217 <option value="unset">Unset</option> 218 <option value="true">True</option> 219 <option value="false">False</option> 220 </select> 221 </div> 222 <div class="flex items-center gap-2"> 223 <Show when={!uploading()}> 224 <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"> 225 <input type="file" id="blob" class="sr-only" onChange={() => uploadBlob()} /> 226 <label class="flex items-center gap-1 px-2 py-1.5 select-none" for="blob"> 227 <span class="iconify lucide--upload text-sm"></span> 228 Upload 229 </label> 230 </div> 231 <p class="text-xs">Metadata will be pasted after the cursor</p> 232 </Show> 233 <Show when={uploading()}> 234 <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 235 <p>Uploading...</p> 236 </Show> 237 </div> 238 <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> 239 <div class="flex items-center gap-x-2"> 240 <label for="mimetype" class="min-w-20 select-none"> 241 MIME type 242 </label> 243 <TextInput id="mimetype" placeholder="Optional" class="w-[15rem]" /> 244 </div> 245 <div class="flex items-center gap-1"> 246 <input id="exif-rm" type="checkbox" checked /> 247 <label for="exif-rm" class="select-none"> 248 Remove EXIF data 249 </label> 250 </div> 251 </div> 252 </div> 253 <Editor 254 content={JSON.stringify(props.create ? placeholder() : props.record, null, 2)} 255 /> 256 <div class="flex flex-col gap-2"> 257 <Show when={notice()}> 258 <div class="text-red-500 dark:text-red-400">{notice()}</div> 259 </Show> 260 <div class="flex items-center justify-end gap-2"> 261 <Show when={!props.create}> 262 <div class="flex items-center gap-1"> 263 <input id="recreate" name="recreate" type="checkbox" /> 264 <label for="recreate" class="text-sm select-none"> 265 Recreate record 266 </label> 267 </div> 268 </Show> 269 <Button 270 onClick={() => 271 props.create ? 272 createRecord(new FormData(formRef)) 273 : editRecord(new FormData(formRef)) 274 } 275 > 276 {props.create ? "Create" : "Edit"} 277 </Button> 278 </div> 279 </div> 280 </form> 281 </div> 282 </Modal> 283 <Tooltip text={`${props.create ? "Create" : "Edit"} record`}> 284 <button 285 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"}`} 286 onclick={() => { 287 setNotice(""); 288 setOpenDialog(true); 289 }} 290 > 291 <div 292 class={props.create ? "iconify lucide--square-pen text-xl" : "iconify lucide--pencil"} 293 /> 294 </button> 295 </Tooltip> 296 </> 297 ); 298};