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