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