1import { Client } from "@atcute/client";
2import { Did } from "@atcute/lexicons";
3import { isNsid, isRecordKey } from "@atcute/lexicons/syntax";
4import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client";
5import { remove } from "@mary/exif-rm";
6import { useNavigate, useParams } from "@solidjs/router";
7import {
8 createEffect,
9 createSignal,
10 For,
11 lazy,
12 onCleanup,
13 onMount,
14 Show,
15 Suspense,
16} from "solid-js";
17import { hasUserScope } from "../auth/scope-utils";
18import { agent, sessions } from "../auth/state";
19import { Button } from "./button.jsx";
20import { Modal } from "./modal.jsx";
21import { addNotification, removeNotification } from "./notification.jsx";
22import { TextInput } from "./text-input.jsx";
23import Tooltip from "./tooltip.jsx";
24
25const Editor = lazy(() => import("../components/editor.jsx").then((m) => ({ default: m.Editor })));
26
27export const editorInstance = { view: null as any };
28export const [placeholder, setPlaceholder] = createSignal<any>();
29
30export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => {
31 const navigate = useNavigate();
32 const params = useParams();
33 const [openDialog, setOpenDialog] = createSignal(false);
34 const [notice, setNotice] = createSignal("");
35 const [openUpload, setOpenUpload] = createSignal(false);
36 const [openInsertMenu, setOpenInsertMenu] = createSignal(false);
37 const [validate, setValidate] = createSignal<boolean | undefined>(undefined);
38 const [isMaximized, setIsMaximized] = createSignal(false);
39 const [isMinimized, setIsMinimized] = createSignal(false);
40 const [collectionError, setCollectionError] = createSignal("");
41 const [rkeyError, setRkeyError] = createSignal("");
42 let blobInput!: HTMLInputElement;
43 let formRef!: HTMLFormElement;
44 let insertMenuRef!: HTMLDivElement;
45
46 createEffect(() => {
47 if (openInsertMenu()) {
48 const handleClickOutside = (e: MouseEvent) => {
49 if (insertMenuRef && !insertMenuRef.contains(e.target as Node)) {
50 setOpenInsertMenu(false);
51 }
52 };
53 document.addEventListener("mousedown", handleClickOutside);
54 onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
55 }
56 });
57
58 onMount(() => {
59 const keyEvent = (ev: KeyboardEvent) => {
60 if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return;
61
62 const key = props.create ? "n" : "e";
63 if (ev.key === key) {
64 ev.preventDefault();
65
66 if (openDialog() && isMinimized()) {
67 setIsMinimized(false);
68 } else if (!openDialog() && !document.querySelector("[data-modal]")) {
69 setOpenDialog(true);
70 }
71 }
72 };
73
74 window.addEventListener("keydown", keyEvent);
75 onCleanup(() => window.removeEventListener("keydown", keyEvent));
76 });
77
78 const defaultPlaceholder = () => {
79 return {
80 $type: "app.bsky.feed.post",
81 text: "This post was sent from PDSls",
82 embed: {
83 $type: "app.bsky.embed.external",
84 external: {
85 uri: "https://pdsls.dev",
86 title: "PDSls",
87 description: "Browse the public data on atproto",
88 },
89 },
90 langs: ["en"],
91 createdAt: new Date().toISOString(),
92 };
93 };
94
95 const getValidateIcon = () => {
96 return (
97 validate() === true ? "lucide--circle-check"
98 : validate() === false ? "lucide--circle-x"
99 : "lucide--circle"
100 );
101 };
102
103 const getValidateLabel = () => {
104 return (
105 validate() === true ? "True"
106 : validate() === false ? "False"
107 : "Unset"
108 );
109 };
110
111 createEffect(() => {
112 if (openDialog()) {
113 setValidate(undefined);
114 setCollectionError("");
115 setRkeyError("");
116 }
117 });
118
119 const createRecord = async (formData: FormData) => {
120 const repo = formData.get("repo")?.toString();
121 if (!repo) return;
122 const rpc = new Client({ handler: new OAuthUserAgent(await getSession(repo as Did)) });
123 const collection = formData.get("collection");
124 const rkey = formData.get("rkey");
125 let record: any;
126 try {
127 record = JSON.parse(editorInstance.view.state.doc.toString());
128 } catch (e: any) {
129 setNotice(e.message);
130 return;
131 }
132 const res = await rpc.post("com.atproto.repo.createRecord", {
133 input: {
134 repo: repo as Did,
135 collection: collection ? collection.toString() : record.$type,
136 rkey: rkey?.toString().length ? rkey?.toString() : undefined,
137 record: record,
138 validate: validate(),
139 },
140 });
141 if (!res.ok) {
142 setNotice(`${res.data.error}: ${res.data.message}`);
143 return;
144 }
145 setOpenDialog(false);
146 const id = addNotification({
147 message: "Record created",
148 type: "success",
149 });
150 setTimeout(() => removeNotification(id), 3000);
151 navigate(`/${res.data.uri}`);
152 };
153
154 const editRecord = async (recreate?: boolean) => {
155 const record = editorInstance.view.state.doc.toString();
156 if (!record) return;
157 const rpc = new Client({ handler: agent()! });
158 try {
159 const editedRecord = JSON.parse(record);
160 if (recreate) {
161 const res = await rpc.post("com.atproto.repo.applyWrites", {
162 input: {
163 repo: agent()!.sub,
164 validate: validate(),
165 writes: [
166 {
167 collection: params.collection as `${string}.${string}.${string}`,
168 rkey: params.rkey!,
169 $type: "com.atproto.repo.applyWrites#delete",
170 },
171 {
172 collection: params.collection as `${string}.${string}.${string}`,
173 rkey: params.rkey,
174 $type: "com.atproto.repo.applyWrites#create",
175 value: editedRecord,
176 },
177 ],
178 },
179 });
180 if (!res.ok) {
181 setNotice(`${res.data.error}: ${res.data.message}`);
182 return;
183 }
184 } else {
185 const res = await rpc.post("com.atproto.repo.applyWrites", {
186 input: {
187 repo: agent()!.sub,
188 validate: validate(),
189 writes: [
190 {
191 collection: params.collection as `${string}.${string}.${string}`,
192 rkey: params.rkey!,
193 $type: "com.atproto.repo.applyWrites#update",
194 value: editedRecord,
195 },
196 ],
197 },
198 });
199 if (!res.ok) {
200 setNotice(`${res.data.error}: ${res.data.message}`);
201 return;
202 }
203 }
204 setOpenDialog(false);
205 const id = addNotification({
206 message: "Record edited",
207 type: "success",
208 });
209 setTimeout(() => removeNotification(id), 3000);
210 props.refetch();
211 } catch (err: any) {
212 setNotice(err.message);
213 }
214 };
215
216 const insertTimestamp = () => {
217 const timestamp = new Date().toISOString();
218 editorInstance.view.dispatch({
219 changes: {
220 from: editorInstance.view.state.selection.main.head,
221 insert: `"${timestamp}"`,
222 },
223 });
224 setOpenInsertMenu(false);
225 };
226
227 const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => {
228 return (
229 <button
230 type="button"
231 class="flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
232 onClick={props.onClick}
233 >
234 <span class={`iconify ${props.icon}`}></span>
235 <span>{props.label}</span>
236 </button>
237 );
238 };
239
240 const FileUpload = (props: { file: File }) => {
241 const [uploading, setUploading] = createSignal(false);
242 const [error, setError] = createSignal("");
243
244 onCleanup(() => (blobInput.value = ""));
245
246 const formatFileSize = (bytes: number) => {
247 if (bytes === 0) return "0 Bytes";
248 const k = 1024;
249 const sizes = ["Bytes", "KB", "MB", "GB"];
250 const i = Math.floor(Math.log(bytes) / Math.log(k));
251 return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
252 };
253
254 const uploadBlob = async () => {
255 let blob: Blob;
256
257 const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value;
258 (document.getElementById("mimetype") as HTMLInputElement).value = "";
259 if (mimetype) blob = new Blob([props.file], { type: mimetype });
260 else blob = props.file;
261
262 if ((document.getElementById("exif-rm") as HTMLInputElement).checked) {
263 const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer()));
264 if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type });
265 }
266
267 const rpc = new Client({ handler: agent()! });
268 setUploading(true);
269 const res = await rpc.post("com.atproto.repo.uploadBlob", {
270 input: blob,
271 });
272 setUploading(false);
273 if (!res.ok) {
274 setError(res.data.error);
275 return;
276 }
277 editorInstance.view.dispatch({
278 changes: {
279 from: editorInstance.view.state.selection.main.head,
280 insert: JSON.stringify(res.data.blob, null, 2),
281 },
282 });
283 setOpenUpload(false);
284 };
285
286 return (
287 <div 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">
288 <h2 class="mb-2 font-semibold">Upload blob</h2>
289 <div class="flex flex-col gap-2 text-sm">
290 <div class="flex flex-col gap-1">
291 <p class="flex gap-1">
292 <span class="truncate">{props.file.name}</span>
293 <span class="shrink-0 text-neutral-600 dark:text-neutral-400">
294 ({formatFileSize(props.file.size)})
295 </span>
296 </p>
297 </div>
298 <div class="flex items-center gap-x-2">
299 <label for="mimetype" class="shrink-0 select-none">
300 MIME type
301 </label>
302 <TextInput id="mimetype" placeholder={props.file.type} />
303 </div>
304 <div class="flex items-center gap-1">
305 <input id="exif-rm" type="checkbox" checked />
306 <label for="exif-rm" class="select-none">
307 Remove EXIF data
308 </label>
309 </div>
310 <p class="text-xs text-neutral-600 dark:text-neutral-400">
311 Metadata will be pasted after the cursor
312 </p>
313 <Show when={error()}>
314 <span class="text-red-500 dark:text-red-400">Error: {error()}</span>
315 </Show>
316 <div class="flex justify-between gap-2">
317 <Button onClick={() => setOpenUpload(false)}>Cancel</Button>
318 <Show when={uploading()}>
319 <div class="flex items-center gap-1">
320 <span class="iconify lucide--loader-circle animate-spin"></span>
321 <span>Uploading</span>
322 </div>
323 </Show>
324 <Show when={!uploading()}>
325 <Button
326 onClick={uploadBlob}
327 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"
328 >
329 Upload
330 </Button>
331 </Show>
332 </div>
333 </div>
334 </div>
335 );
336 };
337
338 return (
339 <>
340 <Modal
341 open={openDialog()}
342 onClose={() => setOpenDialog(false)}
343 closeOnClick={false}
344 nonBlocking={isMinimized()}
345 >
346 <div
347 style="transform: translateX(-50%) translateZ(0);"
348 classList={{
349 "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto absolute top-18 left-1/2 flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-all duration-200 dark:border-neutral-700 starting:opacity-0": true,
350 "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(),
351 "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(),
352 hidden: isMinimized(),
353 }}
354 >
355 <div class="mb-2 flex w-full justify-between text-base">
356 <div class="flex items-center gap-2">
357 <span class="font-semibold select-none">
358 {props.create ? "Creating" : "Editing"} record
359 </span>
360 </div>
361 <div class="flex items-center gap-1">
362 <button
363 type="button"
364 onclick={() => setIsMinimized(true)}
365 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
366 >
367 <span class="iconify lucide--minus"></span>
368 </button>
369 <button
370 type="button"
371 onclick={() => setIsMaximized(!isMaximized())}
372 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
373 >
374 <span
375 class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`}
376 ></span>
377 </button>
378 <button
379 id="close"
380 onclick={() => setOpenDialog(false)}
381 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
382 >
383 <span class="iconify lucide--x"></span>
384 </button>
385 </div>
386 </div>
387 <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2">
388 <Show when={props.create}>
389 <div class="flex flex-wrap items-center gap-1 text-sm">
390 <span>at://</span>
391 <select
392 class="dark:bg-dark-100 max-w-40 truncate rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400"
393 name="repo"
394 id="repo"
395 >
396 <For each={Object.keys(sessions)}>
397 {(session) => (
398 <option value={session} selected={session === agent()?.sub}>
399 {sessions[session].handle ?? session}
400 </option>
401 )}
402 </For>
403 </select>
404 <span>/</span>
405 <TextInput
406 id="collection"
407 name="collection"
408 placeholder="Collection (default: $type)"
409 class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`}
410 onInput={(e) => {
411 const value = e.currentTarget.value;
412 if (!value || isNsid(value)) setCollectionError("");
413 else
414 setCollectionError(
415 "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)",
416 );
417 }}
418 />
419 <span>/</span>
420 <TextInput
421 id="rkey"
422 name="rkey"
423 placeholder="Record key (default: TID)"
424 class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`}
425 onInput={(e) => {
426 const value = e.currentTarget.value;
427 if (!value || isRecordKey(value)) setRkeyError("");
428 else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -");
429 }}
430 />
431 </div>
432 <Show when={collectionError() || rkeyError()}>
433 <div class="text-xs text-red-500 dark:text-red-400">
434 <div>{collectionError()}</div>
435 <div>{rkeyError()}</div>
436 </div>
437 </Show>
438 </Show>
439 <div class="min-h-0 flex-1">
440 <Suspense
441 fallback={
442 <div class="flex h-full items-center justify-center">
443 <span class="iconify lucide--loader-circle animate-spin text-xl"></span>
444 </div>
445 }
446 >
447 <Editor
448 content={JSON.stringify(
449 !props.create ? props.record
450 : params.rkey ? placeholder()
451 : defaultPlaceholder(),
452 null,
453 2,
454 )}
455 />
456 </Suspense>
457 </div>
458 <div class="flex flex-col gap-2">
459 <Show when={notice()}>
460 <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div>
461 </Show>
462 <div class="flex justify-between gap-2">
463 <div class="relative" ref={insertMenuRef}>
464 <button
465 type="button"
466 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 p-1.5 text-base shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
467 onClick={() => setOpenInsertMenu(!openInsertMenu())}
468 >
469 <span class="iconify lucide--plus select-none"></span>
470 </button>
471 <Show when={openInsertMenu()}>
472 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700">
473 <Show when={hasUserScope("blob")}>
474 <MenuItem
475 icon="lucide--upload"
476 label="Upload blob"
477 onClick={() => {
478 setOpenInsertMenu(false);
479 blobInput.click();
480 }}
481 />
482 </Show>
483 <MenuItem
484 icon="lucide--clock"
485 label="Insert timestamp"
486 onClick={insertTimestamp}
487 />
488 </div>
489 </Show>
490 <input
491 type="file"
492 id="blob"
493 class="sr-only"
494 ref={blobInput}
495 onChange={(e) => {
496 if (e.target.files !== null) setOpenUpload(true);
497 }}
498 />
499 </div>
500 <Modal
501 open={openUpload()}
502 onClose={() => setOpenUpload(false)}
503 closeOnClick={false}
504 >
505 <FileUpload file={blobInput.files![0]} />
506 </Modal>
507 <div class="flex items-center justify-end gap-2">
508 <button
509 type="button"
510 class="flex items-center gap-1 rounded-sm p-1.5 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
511 onClick={() =>
512 setValidate(
513 validate() === true ? false
514 : validate() === false ? undefined
515 : true,
516 )
517 }
518 >
519 <Tooltip text={getValidateLabel()}>
520 <span class={`iconify ${getValidateIcon()}`}></span>
521 </Tooltip>
522 <span>Validate</span>
523 </button>
524 <Show when={!props.create && hasUserScope("create") && hasUserScope("delete")}>
525 <Button onClick={() => editRecord(true)}>Recreate</Button>
526 </Show>
527 <Button
528 onClick={() =>
529 props.create ? createRecord(new FormData(formRef)) : editRecord()
530 }
531 >
532 {props.create ? "Create" : "Edit"}
533 </Button>
534 </div>
535 </div>
536 </div>
537 </form>
538 </div>
539 </Modal>
540 <Show when={isMinimized() && openDialog()}>
541 <button
542 class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 fixed right-4 bottom-4 z-30 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-2 shadow-md hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700"
543 onclick={() => setIsMinimized(false)}
544 >
545 <span class="iconify lucide--square-pen text-lg"></span>
546 <span class="text-sm font-medium">{props.create ? "Creating" : "Editing"} record</span>
547 </button>
548 </Show>
549 <Tooltip text={props.create ? "Create record (n)" : "Edit record (e)"}>
550 <button
551 class={`flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`}
552 onclick={() => {
553 setNotice("");
554 setOpenDialog(true);
555 setIsMinimized(false);
556 }}
557 >
558 <div
559 class={props.create ? "iconify lucide--square-pen text-lg" : "iconify lucide--pencil"}
560 />
561 </button>
562 </Tooltip>
563 </>
564 );
565};