1import { Client } from "@atcute/client";
2import { remove } from "@mary/exif-rm";
3import { useNavigate, useParams } from "@solidjs/router";
4import { createSignal, onCleanup, Show } from "solid-js";
5import { Editor, editorView } from "../components/editor.jsx";
6import { agent } from "../components/login.jsx";
7import { setNotif } from "../layout.jsx";
8import { Button } from "./button.jsx";
9import { Modal } from "./modal.jsx";
10import { TextInput } from "./text-input.jsx";
11import Tooltip from "./tooltip.jsx";
12
13export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => {
14 const navigate = useNavigate();
15 const params = useParams();
16 const [openDialog, setOpenDialog] = createSignal(false);
17 const [notice, setNotice] = createSignal("");
18 const [openUpload, setOpenUpload] = createSignal(false);
19 let blobInput!: HTMLInputElement;
20 let formRef!: HTMLFormElement;
21
22 const placeholder = () => {
23 return {
24 $type: "app.bsky.feed.post",
25 text: "This post was sent from PDSls",
26 embed: {
27 $type: "app.bsky.embed.external",
28 external: {
29 uri: "https://pdsls.dev",
30 title: "PDSls",
31 description: "Browse the public data on atproto",
32 },
33 },
34 langs: ["en"],
35 createdAt: new Date().toISOString(),
36 };
37 };
38
39 const createRecord = async (formData: FormData) => {
40 const rpc = new Client({ handler: agent()! });
41 const collection = formData.get("collection");
42 const rkey = formData.get("rkey");
43 const validate = formData.get("validate")?.toString();
44 let record: any;
45 try {
46 record = JSON.parse(editorView.state.doc.toString());
47 } catch (e: any) {
48 setNotice(e.message);
49 return;
50 }
51 const res = await rpc.post("com.atproto.repo.createRecord", {
52 input: {
53 repo: agent()!.sub,
54 collection: collection ? collection.toString() : record.$type,
55 rkey: rkey?.toString().length ? rkey?.toString() : undefined,
56 record: record,
57 validate:
58 validate === "true" ? true
59 : validate === "false" ? false
60 : undefined,
61 },
62 });
63 if (!res.ok) {
64 setNotice(`${res.data.error}: ${res.data.message}`);
65 return;
66 }
67 setOpenDialog(false);
68 setNotif({ show: true, icon: "lucide--file-check", text: "Record created" });
69 navigate(`/${res.data.uri}`);
70 };
71
72 const editRecord = async (formData: FormData) => {
73 const record = editorView.state.doc.toString();
74 const validate =
75 formData.get("validate")?.toString() === "true" ? true
76 : formData.get("validate")?.toString() === "false" ? false
77 : undefined;
78 if (!record) return;
79 const rpc = new Client({ handler: agent()! });
80 try {
81 const editedRecord = JSON.parse(record);
82 if (formData.get("recreate")) {
83 const res = await rpc.post("com.atproto.repo.applyWrites", {
84 input: {
85 repo: agent()!.sub,
86 validate: validate,
87 writes: [
88 {
89 collection: params.collection as `${string}.${string}.${string}`,
90 rkey: params.rkey,
91 $type: "com.atproto.repo.applyWrites#delete",
92 },
93 {
94 collection: params.collection as `${string}.${string}.${string}`,
95 rkey: params.rkey,
96 $type: "com.atproto.repo.applyWrites#create",
97 value: editedRecord,
98 },
99 ],
100 },
101 });
102 if (!res.ok) {
103 setNotice(`${res.data.error}: ${res.data.message}`);
104 return;
105 }
106 } else {
107 const res = await rpc.post("com.atproto.repo.putRecord", {
108 input: {
109 repo: agent()!.sub,
110 collection: params.collection as `${string}.${string}.${string}`,
111 rkey: params.rkey,
112 record: editedRecord,
113 validate: validate,
114 },
115 });
116 if (!res.ok) {
117 setNotice(`${res.data.error}: ${res.data.message}`);
118 return;
119 }
120 }
121 setOpenDialog(false);
122 setNotif({ show: true, icon: "lucide--file-check", text: "Record edited" });
123 props.refetch();
124 } catch (err: any) {
125 setNotice(err.message);
126 }
127 };
128
129 const FileUpload = (props: { file: File }) => {
130 const [uploading, setUploading] = createSignal(false);
131 const [error, setError] = createSignal("");
132
133 onCleanup(() => (blobInput.value = ""));
134
135 const formatFileSize = (bytes: number) => {
136 if (bytes === 0) return "0 Bytes";
137 const k = 1024;
138 const sizes = ["Bytes", "KB", "MB", "GB"];
139 const i = Math.floor(Math.log(bytes) / Math.log(k));
140 return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
141 };
142
143 const uploadBlob = async () => {
144 let blob: Blob;
145
146 const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value;
147 (document.getElementById("mimetype") as HTMLInputElement).value = "";
148 if (mimetype) blob = new Blob([props.file], { type: mimetype });
149 else blob = props.file;
150
151 if ((document.getElementById("exif-rm") as HTMLInputElement).checked) {
152 const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer()));
153 if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type });
154 }
155
156 const rpc = new Client({ handler: agent()! });
157 setUploading(true);
158 const res = await rpc.post("com.atproto.repo.uploadBlob", {
159 input: blob,
160 });
161 setUploading(false);
162 if (!res.ok) {
163 setError(res.data.error);
164 return;
165 }
166 editorView.dispatch({
167 changes: {
168 from: editorView.state.selection.main.head,
169 insert: JSON.stringify(res.data.blob, null, 2),
170 },
171 });
172 setOpenUpload(false);
173 };
174
175 return (
176 <div class="dark:bg-dark-300 dark:shadow-dark-800 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">
177 <h2 class="mb-2 font-semibold">Upload blob</h2>
178 <div class="flex flex-col gap-2 text-sm">
179 <div class="flex flex-col gap-1">
180 <p class="flex gap-1">
181 <span class="truncate">{props.file.name}</span>
182 <span class="shrink-0 text-neutral-600 dark:text-neutral-400">
183 ({formatFileSize(props.file.size)})
184 </span>
185 </p>
186 </div>
187 <div class="flex items-center gap-x-2">
188 <label for="mimetype" class="shrink-0 select-none">
189 MIME type
190 </label>
191 <TextInput id="mimetype" placeholder={props.file.type} />
192 </div>
193 <div class="flex items-center gap-1">
194 <input id="exif-rm" type="checkbox" checked />
195 <label for="exif-rm" class="select-none">
196 Remove EXIF data
197 </label>
198 </div>
199 <p class="text-xs text-neutral-600 dark:text-neutral-400">
200 Metadata will be pasted after the cursor
201 </p>
202 <Show when={error()}>
203 <span class="text-red-500 dark:text-red-400">Error: {error()}</span>
204 </Show>
205 <div class="flex justify-between gap-2">
206 <Button onClick={() => setOpenUpload(false)}>Cancel</Button>
207 <Show when={uploading()}>
208 <div class="flex items-center gap-1">
209 <span class="iconify lucide--loader-circle animate-spin"></span>
210 <span>Uploading</span>
211 </div>
212 </Show>
213 <Show when={!uploading()}>
214 <Button
215 onClick={uploadBlob}
216 class="dark:shadow-dark-800 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"
217 >
218 Upload
219 </Button>
220 </Show>
221 </div>
222 </div>
223 </div>
224 );
225 };
226
227 return (
228 <>
229 <Modal open={openDialog()} onClose={() => setOpenDialog(false)} closeOnClick={false}>
230 <div class="dark:bg-dark-300 dark:shadow-dark-800 absolute top-16 left-[50%] w-screen -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 sm:w-xl lg:w-[48rem] dark:border-neutral-700 starting:opacity-0">
231 <div class="mb-2 flex w-full justify-between">
232 <div class="flex items-center gap-1 font-semibold">
233 <span
234 class={`iconify ${props.create ? "lucide--square-pen" : "lucide--pencil"}`}
235 ></span>
236 <span>{props.create ? "Creating" : "Editing"} record</span>
237 </div>
238 <button
239 onclick={() => setOpenDialog(false)}
240 class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
241 >
242 <span class="iconify lucide--x"></span>
243 </button>
244 </div>
245 <form ref={formRef} class="flex flex-col gap-y-2">
246 <div class="flex w-fit flex-col gap-y-1 text-sm">
247 <Show when={props.create}>
248 <div class="flex items-center gap-x-2">
249 <label for="collection" class="min-w-20 select-none">
250 Collection
251 </label>
252 <TextInput
253 id="collection"
254 name="collection"
255 placeholder="Optional (default: $type)"
256 class="w-[15rem]"
257 />
258 </div>
259 <div class="flex items-center gap-x-2">
260 <label for="rkey" class="min-w-20 select-none">
261 Record key
262 </label>
263 <TextInput
264 id="rkey"
265 name="rkey"
266 placeholder="Optional (default: TID)"
267 class="w-[15rem]"
268 />
269 </div>
270 </Show>
271 <div class="flex items-center gap-x-2">
272 <label for="validate" class="min-w-20 select-none">
273 Validate
274 </label>
275 <select
276 name="validate"
277 id="validate"
278 class="dark:bg-dark-100 dark:shadow-dark-800 rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 shadow-xs focus:outline-[1px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200"
279 >
280 <option value="unset">Unset</option>
281 <option value="true">True</option>
282 <option value="false">False</option>
283 </select>
284 </div>
285 </div>
286 <Editor
287 content={JSON.stringify(props.create ? placeholder() : props.record, null, 2)}
288 />
289 <div class="flex flex-col gap-2">
290 <Show when={notice()}>
291 <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div>
292 </Show>
293 <div class="flex justify-between gap-2">
294 <div class="dark:hover:bg-dark-200 dark:shadow-dark-800 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">
295 <input
296 type="file"
297 id="blob"
298 class="sr-only"
299 ref={blobInput}
300 onChange={(e) => {
301 if (e.target.files !== null) setOpenUpload(true);
302 }}
303 />
304 <label class="flex items-center gap-1 px-2 py-1.5 select-none" for="blob">
305 <span class="iconify lucide--upload"></span>
306 Upload
307 </label>
308 </div>
309 <Modal
310 open={openUpload()}
311 onClose={() => setOpenUpload(false)}
312 closeOnClick={false}
313 >
314 <FileUpload file={blobInput.files![0]} />
315 </Modal>
316 <div class="flex items-center justify-end gap-2">
317 <Show when={!props.create}>
318 <div class="flex items-center gap-1">
319 <input id="recreate" name="recreate" type="checkbox" />
320 <label for="recreate" class="text-sm select-none">
321 Recreate record
322 </label>
323 </div>
324 </Show>
325 <Button
326 onClick={() =>
327 props.create ?
328 createRecord(new FormData(formRef))
329 : editRecord(new FormData(formRef))
330 }
331 >
332 {props.create ? "Create" : "Edit"}
333 </Button>
334 </div>
335 </div>
336 </div>
337 </form>
338 </div>
339 </Modal>
340 <Tooltip text={`${props.create ? "Create" : "Edit"} record`}>
341 <button
342 class={`flex items-center p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}`}
343 onclick={() => {
344 setNotice("");
345 setOpenDialog(true);
346 }}
347 >
348 <div
349 class={props.create ? "iconify lucide--square-pen text-xl" : "iconify lucide--pencil"}
350 />
351 </button>
352 </Tooltip>
353 </>
354 );
355};