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 onclick={() => setOpenDialog(false)} class="flex items-center">
176 <span class="iconify lucide--x text-lg hover:text-neutral-500 dark:hover:text-neutral-400"></span>
177 </button>
178 </div>
179 <form ref={formRef} class="flex flex-col gap-y-2">
180 <div class="flex w-fit flex-col gap-y-1 text-xs sm:text-sm">
181 <Show when={props.create}>
182 <div class="flex items-center gap-x-2">
183 <label for="collection" class="min-w-20 select-none">
184 Collection
185 </label>
186 <TextInput
187 id="collection"
188 name="collection"
189 placeholder="Optional (default: record type)"
190 class="w-[15rem]"
191 />
192 </div>
193 <div class="flex items-center gap-x-2">
194 <label for="rkey" class="min-w-20 select-none">
195 Record key
196 </label>
197 <TextInput
198 id="rkey"
199 name="rkey"
200 placeholder="Optional (default: TID)"
201 class="w-[15rem]"
202 />
203 </div>
204 </Show>
205 <div class="flex items-center gap-x-2">
206 <label for="validate" class="min-w-20 select-none">
207 Validate
208 </label>
209 <select
210 name="validate"
211 id="validate"
212 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"
213 >
214 <option value="unset">Unset</option>
215 <option value="true">True</option>
216 <option value="false">False</option>
217 </select>
218 </div>
219 <div class="flex items-center gap-2">
220 <Show when={!uploading()}>
221 <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">
222 <input type="file" id="blob" class="sr-only" onChange={() => uploadBlob()} />
223 <label class="flex items-center gap-1 px-2 py-1.5 select-none" for="blob">
224 <span class="iconify lucide--upload text-sm"></span>
225 Upload
226 </label>
227 </div>
228 <p class="text-xs">Metadata will be pasted after the cursor</p>
229 </Show>
230 <Show when={uploading()}>
231 <span class="iconify lucide--loader-circle animate-spin text-xl"></span>
232 <p>Uploading...</p>
233 </Show>
234 </div>
235 <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
236 <div class="flex items-center gap-x-2">
237 <label for="mimetype" class="min-w-20 select-none">
238 MIME type
239 </label>
240 <TextInput id="mimetype" placeholder="Optional" class="w-[15rem]" />
241 </div>
242 <div class="flex items-center gap-1">
243 <input id="exif-rm" type="checkbox" checked />
244 <label for="exif-rm" class="select-none">
245 Remove EXIF data
246 </label>
247 </div>
248 </div>
249 </div>
250 <Editor
251 content={JSON.stringify(props.create ? placeholder() : props.record, null, 2)}
252 />
253 <div class="flex flex-col gap-2">
254 <Show when={notice()}>
255 <div class="text-red-500 dark:text-red-400">{notice()}</div>
256 </Show>
257 <div class="flex items-center justify-end gap-2">
258 <Show when={!props.create}>
259 <div class="flex items-center gap-1">
260 <input id="recreate" name="recreate" type="checkbox" />
261 <label for="recreate" class="text-sm select-none">
262 Recreate record
263 </label>
264 </div>
265 </Show>
266 <Button
267 onClick={() =>
268 props.create ?
269 createRecord(new FormData(formRef))
270 : editRecord(new FormData(formRef))
271 }
272 >
273 {props.create ? "Create" : "Edit"}
274 </Button>
275 </div>
276 </div>
277 </form>
278 </div>
279 </Modal>
280 <Tooltip text={`${props.create ? "Create" : "Edit"} record`}>
281 <button
282 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"}`}
283 onclick={() => {
284 setNotice("");
285 setOpenDialog(true);
286 }}
287 >
288 <div
289 class={props.create ? "iconify lucide--square-pen text-xl" : "iconify lucide--pencil"}
290 />
291 </button>
292 </Tooltip>
293 </>
294 );
295};