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