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