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