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