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