add did insertion

juli.ee f14799d5 a56e4635

verified
+34 -121
src/components/create.tsx src/components/create/index.tsx
··· 2 import { Did } from "@atcute/lexicons"; 3 import { isNsid, isRecordKey } from "@atcute/lexicons/syntax"; 4 import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 - import { remove } from "@mary/exif-rm"; 6 import { useNavigate, useParams } from "@solidjs/router"; 7 import { 8 createEffect, ··· 14 Show, 15 Suspense, 16 } 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"; 24 25 - const Editor = lazy(() => import("../components/editor.jsx").then((m) => ({ default: m.Editor }))); 26 27 - export const editorInstance = { view: null as any }; 28 - export const [placeholder, setPlaceholder] = createSignal<any>(); 29 30 export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 31 const navigate = useNavigate(); ··· 34 const [notice, setNotice] = createSignal(""); 35 const [openUpload, setOpenUpload] = createSignal(false); 36 const [openInsertMenu, setOpenInsertMenu] = createSignal(false); 37 const [validate, setValidate] = createSignal<boolean | undefined>(undefined); 38 const [isMaximized, setIsMaximized] = createSignal(false); 39 const [isMinimized, setIsMinimized] = createSignal(false); ··· 225 setOpenInsertMenu(false); 226 }; 227 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 - ); 337 }; 338 339 return ( ··· 471 </button> 472 <Show when={openInsertMenu()}> 473 <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"> 474 <Show when={hasUserScope("blob")}> 475 <MenuItem 476 icon="lucide--upload" ··· 503 onClose={() => setOpenUpload(false)} 504 closeOnClick={false} 505 > 506 - <FileUpload file={blobInput.files![0]} /> 507 </Modal> 508 <div class="flex items-center justify-end gap-2"> 509 <button
··· 2 import { Did } from "@atcute/lexicons"; 3 import { isNsid, isRecordKey } from "@atcute/lexicons/syntax"; 4 import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 import { useNavigate, useParams } from "@solidjs/router"; 6 import { 7 createEffect, ··· 13 Show, 14 Suspense, 15 } from "solid-js"; 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"; 27 28 + const Editor = lazy(() => import("../editor.jsx").then((m) => ({ default: m.Editor }))); 29 30 + export { editorInstance, placeholder, setPlaceholder }; 31 32 export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 33 const navigate = useNavigate(); ··· 36 const [notice, setNotice] = createSignal(""); 37 const [openUpload, setOpenUpload] = createSignal(false); 38 const [openInsertMenu, setOpenInsertMenu] = createSignal(false); 39 + const [openHandleDialog, setOpenHandleDialog] = createSignal(false); 40 const [validate, setValidate] = createSignal<boolean | undefined>(undefined); 41 const [isMaximized, setIsMaximized] = createSignal(false); 42 const [isMinimized, setIsMinimized] = createSignal(false); ··· 228 setOpenInsertMenu(false); 229 }; 230 231 + const insertDidFromHandle = () => { 232 + setOpenInsertMenu(false); 233 + setOpenHandleDialog(true); 234 }; 235 236 return ( ··· 368 </button> 369 <Show when={openInsertMenu()}> 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 + /> 376 <Show when={hasUserScope("blob")}> 377 <MenuItem 378 icon="lucide--upload" ··· 405 onClose={() => setOpenUpload(false)} 406 closeOnClick={false} 407 > 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)} /> 420 </Modal> 421 <div class="flex items-center justify-end gap-2"> 422 <button
+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
···
··· 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 + };
+12
src/components/create/menu-item.tsx
···
··· 1 + export const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => { 2 + return ( 3 + <button 4 + type="button" 5 + 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" 6 + onClick={props.onClick} 7 + > 8 + <span class={`iconify ${props.icon}`}></span> 9 + <span>{props.label}</span> 10 + </button> 11 + ); 12 + };
+4
src/components/create/state.ts
···
··· 1 + import { createSignal } from "solid-js"; 2 + 3 + export const editorInstance = { view: null as any }; 4 + export const [placeholder, setPlaceholder] = createSignal<any>();
+1 -1
src/components/editor.tsx
··· 7 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 import { basicSetup, EditorView } from "codemirror"; 9 import { onCleanup, onMount } from "solid-js"; 10 - import { editorInstance } from "./create"; 11 12 const Editor = (props: { content: string }) => { 13 let editorDiv!: HTMLDivElement;
··· 7 import { basicLight } from "@fsegurai/codemirror-theme-basic-light"; 8 import { basicSetup, EditorView } from "codemirror"; 9 import { onCleanup, onMount } from "solid-js"; 10 + import { editorInstance } from "./create/state"; 11 12 const Editor = (props: { content: string }) => { 13 let editorDiv!: HTMLDivElement;
+1 -1
src/layout.tsx
··· 5 import { AccountManager } from "./auth/account.jsx"; 6 import { hasUserScope } from "./auth/scope-utils"; 7 import { agent } from "./auth/state.js"; 8 - import { RecordEditor } from "./components/create.jsx"; 9 import { DropdownMenu, MenuProvider, MenuSeparator, NavMenu } from "./components/dropdown.jsx"; 10 import { NavBar } from "./components/navbar.jsx"; 11 import { NotificationContainer } from "./components/notification.jsx";
··· 5 import { AccountManager } from "./auth/account.jsx"; 6 import { hasUserScope } from "./auth/scope-utils"; 7 import { agent } from "./auth/state.js"; 8 + import { RecordEditor } from "./components/create"; 9 import { DropdownMenu, MenuProvider, MenuSeparator, NavMenu } from "./components/dropdown.jsx"; 10 import { NavBar } from "./components/navbar.jsx"; 11 import { NotificationContainer } from "./components/notification.jsx";
+1 -1
src/views/record.tsx
··· 12 import { agent } from "../auth/state"; 13 import { Backlinks } from "../components/backlinks.jsx"; 14 import { Button } from "../components/button.jsx"; 15 - import { RecordEditor, setPlaceholder } from "../components/create.jsx"; 16 import { 17 CopyMenu, 18 DropdownMenu,
··· 12 import { agent } from "../auth/state"; 13 import { Backlinks } from "../components/backlinks.jsx"; 14 import { Button } from "../components/button.jsx"; 15 + import { RecordEditor, setPlaceholder } from "../components/create"; 16 import { 17 CopyMenu, 18 DropdownMenu,