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