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