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