forked from
pds.ls/pdsls
atproto explorer
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};