+34
-121
src/components/create.tsx
src/components/create/index.tsx
+34
-121
src/components/create.tsx
src/components/create/index.tsx
···
2
2
import { Did } from "@atcute/lexicons";
3
3
import { isNsid, isRecordKey } from "@atcute/lexicons/syntax";
4
4
import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client";
5
-
import { remove } from "@mary/exif-rm";
6
5
import { useNavigate, useParams } from "@solidjs/router";
7
6
import {
8
7
createEffect,
···
14
13
Show,
15
14
Suspense,
16
15
} from "solid-js";
17
-
import { hasUserScope } from "../auth/scope-utils";
18
-
import { agent, sessions } from "../auth/state";
19
-
import { Button } from "./button.jsx";
20
-
import { Modal } from "./modal.jsx";
21
-
import { addNotification, removeNotification } from "./notification.jsx";
22
-
import { TextInput } from "./text-input.jsx";
23
-
import Tooltip from "./tooltip.jsx";
16
+
import { hasUserScope } from "../../auth/scope-utils";
17
+
import { agent, sessions } from "../../auth/state";
18
+
import { Button } from "../button.jsx";
19
+
import { Modal } from "../modal.jsx";
20
+
import { addNotification, removeNotification } from "../notification.jsx";
21
+
import { TextInput } from "../text-input.jsx";
22
+
import Tooltip from "../tooltip.jsx";
23
+
import { FileUpload } from "./file-upload";
24
+
import { HandleInput } from "./handle-input";
25
+
import { MenuItem } from "./menu-item";
26
+
import { editorInstance, placeholder, setPlaceholder } from "./state";
24
27
25
-
const Editor = lazy(() => import("../components/editor.jsx").then((m) => ({ default: m.Editor })));
28
+
const Editor = lazy(() => import("../editor.jsx").then((m) => ({ default: m.Editor })));
26
29
27
-
export const editorInstance = { view: null as any };
28
-
export const [placeholder, setPlaceholder] = createSignal<any>();
30
+
export { editorInstance, placeholder, setPlaceholder };
29
31
30
32
export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => {
31
33
const navigate = useNavigate();
···
34
36
const [notice, setNotice] = createSignal("");
35
37
const [openUpload, setOpenUpload] = createSignal(false);
36
38
const [openInsertMenu, setOpenInsertMenu] = createSignal(false);
39
+
const [openHandleDialog, setOpenHandleDialog] = createSignal(false);
37
40
const [validate, setValidate] = createSignal<boolean | undefined>(undefined);
38
41
const [isMaximized, setIsMaximized] = createSignal(false);
39
42
const [isMinimized, setIsMinimized] = createSignal(false);
···
225
228
setOpenInsertMenu(false);
226
229
};
227
230
228
-
const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => {
229
-
return (
230
-
<button
231
-
type="button"
232
-
class="flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
233
-
onClick={props.onClick}
234
-
>
235
-
<span class={`iconify ${props.icon}`}></span>
236
-
<span>{props.label}</span>
237
-
</button>
238
-
);
239
-
};
240
-
241
-
const FileUpload = (props: { file: File }) => {
242
-
const [uploading, setUploading] = createSignal(false);
243
-
const [error, setError] = createSignal("");
244
-
245
-
onCleanup(() => (blobInput.value = ""));
246
-
247
-
const formatFileSize = (bytes: number) => {
248
-
if (bytes === 0) return "0 Bytes";
249
-
const k = 1024;
250
-
const sizes = ["Bytes", "KB", "MB", "GB"];
251
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
252
-
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
253
-
};
254
-
255
-
const uploadBlob = async () => {
256
-
let blob: Blob;
257
-
258
-
const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value;
259
-
(document.getElementById("mimetype") as HTMLInputElement).value = "";
260
-
if (mimetype) blob = new Blob([props.file], { type: mimetype });
261
-
else blob = props.file;
262
-
263
-
if ((document.getElementById("exif-rm") as HTMLInputElement).checked) {
264
-
const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer()));
265
-
if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type });
266
-
}
267
-
268
-
const rpc = new Client({ handler: agent()! });
269
-
setUploading(true);
270
-
const res = await rpc.post("com.atproto.repo.uploadBlob", {
271
-
input: blob,
272
-
});
273
-
setUploading(false);
274
-
if (!res.ok) {
275
-
setError(res.data.error);
276
-
return;
277
-
}
278
-
editorInstance.view.dispatch({
279
-
changes: {
280
-
from: editorInstance.view.state.selection.main.head,
281
-
insert: JSON.stringify(res.data.blob, null, 2),
282
-
},
283
-
});
284
-
setOpenUpload(false);
285
-
};
286
-
287
-
return (
288
-
<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">
289
-
<h2 class="mb-2 font-semibold">Upload blob</h2>
290
-
<div class="flex flex-col gap-2 text-sm">
291
-
<div class="flex flex-col gap-1">
292
-
<p class="flex gap-1">
293
-
<span class="truncate">{props.file.name}</span>
294
-
<span class="shrink-0 text-neutral-600 dark:text-neutral-400">
295
-
({formatFileSize(props.file.size)})
296
-
</span>
297
-
</p>
298
-
</div>
299
-
<div class="flex items-center gap-x-2">
300
-
<label for="mimetype" class="shrink-0 select-none">
301
-
MIME type
302
-
</label>
303
-
<TextInput id="mimetype" placeholder={props.file.type} />
304
-
</div>
305
-
<div class="flex items-center gap-1">
306
-
<input id="exif-rm" type="checkbox" checked />
307
-
<label for="exif-rm" class="select-none">
308
-
Remove EXIF data
309
-
</label>
310
-
</div>
311
-
<p class="text-xs text-neutral-600 dark:text-neutral-400">
312
-
Metadata will be pasted after the cursor
313
-
</p>
314
-
<Show when={error()}>
315
-
<span class="text-red-500 dark:text-red-400">Error: {error()}</span>
316
-
</Show>
317
-
<div class="flex justify-between gap-2">
318
-
<Button onClick={() => setOpenUpload(false)}>Cancel</Button>
319
-
<Show when={uploading()}>
320
-
<div class="flex items-center gap-1">
321
-
<span class="iconify lucide--loader-circle animate-spin"></span>
322
-
<span>Uploading</span>
323
-
</div>
324
-
</Show>
325
-
<Show when={!uploading()}>
326
-
<Button
327
-
onClick={uploadBlob}
328
-
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"
329
-
>
330
-
Upload
331
-
</Button>
332
-
</Show>
333
-
</div>
334
-
</div>
335
-
</div>
336
-
);
231
+
const insertDidFromHandle = () => {
232
+
setOpenInsertMenu(false);
233
+
setOpenHandleDialog(true);
337
234
};
338
235
339
236
return (
···
471
368
</button>
472
369
<Show when={openInsertMenu()}>
473
370
<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">
371
+
<MenuItem
372
+
icon="lucide--id-card"
373
+
label="Insert DID"
374
+
onClick={insertDidFromHandle}
375
+
/>
474
376
<Show when={hasUserScope("blob")}>
475
377
<MenuItem
476
378
icon="lucide--upload"
···
503
405
onClose={() => setOpenUpload(false)}
504
406
closeOnClick={false}
505
407
>
506
-
<FileUpload file={blobInput.files![0]} />
408
+
<FileUpload
409
+
file={blobInput.files![0]}
410
+
blobInput={blobInput}
411
+
onClose={() => setOpenUpload(false)}
412
+
/>
413
+
</Modal>
414
+
<Modal
415
+
open={openHandleDialog()}
416
+
onClose={() => setOpenHandleDialog(false)}
417
+
closeOnClick={false}
418
+
>
419
+
<HandleInput onClose={() => setOpenHandleDialog(false)} />
507
420
</Modal>
508
421
<div class="flex items-center justify-end gap-2">
509
422
<button
+109
src/components/create/file-upload.tsx
+109
src/components/create/file-upload.tsx
···
1
+
import { Client } from "@atcute/client";
2
+
import { remove } from "@mary/exif-rm";
3
+
import { createSignal, onCleanup, Show } from "solid-js";
4
+
import { agent } from "../../auth/state";
5
+
import { Button } from "../button.jsx";
6
+
import { TextInput } from "../text-input.jsx";
7
+
import { editorInstance } from "./state";
8
+
9
+
export const FileUpload = (props: {
10
+
file: File;
11
+
blobInput: HTMLInputElement;
12
+
onClose: () => void;
13
+
}) => {
14
+
const [uploading, setUploading] = createSignal(false);
15
+
const [error, setError] = createSignal("");
16
+
17
+
onCleanup(() => (props.blobInput.value = ""));
18
+
19
+
const formatFileSize = (bytes: number) => {
20
+
if (bytes === 0) return "0 Bytes";
21
+
const k = 1024;
22
+
const sizes = ["Bytes", "KB", "MB", "GB"];
23
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
24
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
25
+
};
26
+
27
+
const uploadBlob = async () => {
28
+
let blob: Blob;
29
+
30
+
const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value;
31
+
(document.getElementById("mimetype") as HTMLInputElement).value = "";
32
+
if (mimetype) blob = new Blob([props.file], { type: mimetype });
33
+
else blob = props.file;
34
+
35
+
if ((document.getElementById("exif-rm") as HTMLInputElement).checked) {
36
+
const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer()));
37
+
if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type });
38
+
}
39
+
40
+
const rpc = new Client({ handler: agent()! });
41
+
setUploading(true);
42
+
const res = await rpc.post("com.atproto.repo.uploadBlob", {
43
+
input: blob,
44
+
});
45
+
setUploading(false);
46
+
if (!res.ok) {
47
+
setError(res.data.error);
48
+
return;
49
+
}
50
+
editorInstance.view.dispatch({
51
+
changes: {
52
+
from: editorInstance.view.state.selection.main.head,
53
+
insert: JSON.stringify(res.data.blob, null, 2),
54
+
},
55
+
});
56
+
props.onClose();
57
+
};
58
+
59
+
return (
60
+
<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">
61
+
<h2 class="mb-2 font-semibold">Upload blob</h2>
62
+
<div class="flex flex-col gap-2 text-sm">
63
+
<div class="flex flex-col gap-1">
64
+
<p class="flex gap-1">
65
+
<span class="truncate">{props.file.name}</span>
66
+
<span class="shrink-0 text-neutral-600 dark:text-neutral-400">
67
+
({formatFileSize(props.file.size)})
68
+
</span>
69
+
</p>
70
+
</div>
71
+
<div class="flex items-center gap-x-2">
72
+
<label for="mimetype" class="shrink-0 select-none">
73
+
MIME type
74
+
</label>
75
+
<TextInput id="mimetype" placeholder={props.file.type} />
76
+
</div>
77
+
<div class="flex items-center gap-1">
78
+
<input id="exif-rm" type="checkbox" checked />
79
+
<label for="exif-rm" class="select-none">
80
+
Remove EXIF data
81
+
</label>
82
+
</div>
83
+
<p class="text-xs text-neutral-600 dark:text-neutral-400">
84
+
Metadata will be pasted after the cursor
85
+
</p>
86
+
<Show when={error()}>
87
+
<span class="text-red-500 dark:text-red-400">Error: {error()}</span>
88
+
</Show>
89
+
<div class="flex justify-between gap-2">
90
+
<Button onClick={props.onClose}>Cancel</Button>
91
+
<Show when={uploading()}>
92
+
<div class="flex items-center gap-1">
93
+
<span class="iconify lucide--loader-circle animate-spin"></span>
94
+
<span>Uploading</span>
95
+
</div>
96
+
</Show>
97
+
<Show when={!uploading()}>
98
+
<Button
99
+
onClick={uploadBlob}
100
+
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"
101
+
>
102
+
Upload
103
+
</Button>
104
+
</Show>
105
+
</div>
106
+
</div>
107
+
</div>
108
+
);
109
+
};
+87
src/components/create/handle-input.tsx
+87
src/components/create/handle-input.tsx
···
1
+
import { Handle } from "@atcute/lexicons";
2
+
import { createSignal, Show } from "solid-js";
3
+
import { resolveHandle } from "../../utils/api";
4
+
import { Button } from "../button.jsx";
5
+
import { TextInput } from "../text-input.jsx";
6
+
import { editorInstance } from "./state";
7
+
8
+
export const HandleInput = (props: { onClose: () => void }) => {
9
+
const [resolving, setResolving] = createSignal(false);
10
+
const [error, setError] = createSignal("");
11
+
let handleFormRef!: HTMLFormElement;
12
+
13
+
const resolveDid = async (e: SubmitEvent) => {
14
+
e.preventDefault();
15
+
const formData = new FormData(handleFormRef);
16
+
const handleValue = formData.get("handle")?.toString().trim();
17
+
18
+
if (!handleValue) {
19
+
setError("Please enter a handle");
20
+
return;
21
+
}
22
+
23
+
setResolving(true);
24
+
setError("");
25
+
try {
26
+
const did = await resolveHandle(handleValue as Handle);
27
+
editorInstance.view.dispatch({
28
+
changes: {
29
+
from: editorInstance.view.state.selection.main.head,
30
+
insert: `"${did}"`,
31
+
},
32
+
});
33
+
props.onClose();
34
+
handleFormRef.reset();
35
+
} catch (err: any) {
36
+
setError(err.message || "Failed to resolve handle");
37
+
} finally {
38
+
setResolving(false);
39
+
}
40
+
};
41
+
42
+
return (
43
+
<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">
44
+
<h2 class="mb-2 font-semibold">Insert DID from handle</h2>
45
+
<form ref={handleFormRef} onSubmit={resolveDid} class="flex flex-col gap-2 text-sm">
46
+
<div class="flex flex-col gap-1">
47
+
<label for="handle-input" class="select-none">
48
+
Handle
49
+
</label>
50
+
<TextInput id="handle-input" name="handle" placeholder="user.bsky.social" />
51
+
</div>
52
+
<p class="text-xs text-neutral-600 dark:text-neutral-400">
53
+
DID will be pasted after the cursor
54
+
</p>
55
+
<Show when={error()}>
56
+
<span class="text-red-500 dark:text-red-400">Error: {error()}</span>
57
+
</Show>
58
+
<div class="flex justify-between gap-2">
59
+
<Button
60
+
type="button"
61
+
onClick={() => {
62
+
props.onClose();
63
+
handleFormRef.reset();
64
+
setError("");
65
+
}}
66
+
>
67
+
Cancel
68
+
</Button>
69
+
<Show when={resolving()}>
70
+
<div class="flex items-center gap-1">
71
+
<span class="iconify lucide--loader-circle animate-spin"></span>
72
+
<span>Resolving</span>
73
+
</div>
74
+
</Show>
75
+
<Show when={!resolving()}>
76
+
<Button
77
+
type="submit"
78
+
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"
79
+
>
80
+
Insert
81
+
</Button>
82
+
</Show>
83
+
</div>
84
+
</form>
85
+
</div>
86
+
);
87
+
};
+4
src/components/create/state.ts
+4
src/components/create/state.ts
+1
-1
src/components/editor.tsx
+1
-1
src/components/editor.tsx
···
7
7
import { basicLight } from "@fsegurai/codemirror-theme-basic-light";
8
8
import { basicSetup, EditorView } from "codemirror";
9
9
import { onCleanup, onMount } from "solid-js";
10
-
import { editorInstance } from "./create";
10
+
import { editorInstance } from "./create/state";
11
11
12
12
const Editor = (props: { content: string }) => {
13
13
let editorDiv!: HTMLDivElement;
+1
-1
src/layout.tsx
+1
-1
src/layout.tsx
···
5
5
import { AccountManager } from "./auth/account.jsx";
6
6
import { hasUserScope } from "./auth/scope-utils";
7
7
import { agent } from "./auth/state.js";
8
-
import { RecordEditor } from "./components/create.jsx";
8
+
import { RecordEditor } from "./components/create";
9
9
import { DropdownMenu, MenuProvider, MenuSeparator, NavMenu } from "./components/dropdown.jsx";
10
10
import { NavBar } from "./components/navbar.jsx";
11
11
import { NotificationContainer } from "./components/notification.jsx";
+1
-1
src/views/record.tsx
+1
-1
src/views/record.tsx
···
12
12
import { agent } from "../auth/state";
13
13
import { Backlinks } from "../components/backlinks.jsx";
14
14
import { Button } from "../components/button.jsx";
15
-
import { RecordEditor, setPlaceholder } from "../components/create.jsx";
15
+
import { RecordEditor, setPlaceholder } from "../components/create";
16
16
import {
17
17
CopyMenu,
18
18
DropdownMenu,