import { Client } from "@atcute/client"; import { Did } from "@atcute/lexicons"; import { isNsid, isRecordKey } from "@atcute/lexicons/syntax"; import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; import { useNavigate, useParams } from "@solidjs/router"; import { createEffect, createSignal, For, lazy, onCleanup, onMount, Show, Suspense, } from "solid-js"; import { hasUserScope } from "../../auth/scope-utils"; import { agent, sessions } from "../../auth/state"; import { Button } from "../button.jsx"; import { Modal } from "../modal.jsx"; import { addNotification, removeNotification } from "../notification.jsx"; import { showPermissionPrompt } from "../permission-prompt"; import { TextInput } from "../text-input.jsx"; import Tooltip from "../tooltip.jsx"; import { ConfirmSubmit } from "./confirm-submit"; import { FileUpload } from "./file-upload"; import { HandleInput } from "./handle-input"; import { MenuItem } from "./menu-item"; import { editorInstance, placeholder, setPlaceholder } from "./state"; const Editor = lazy(() => import("../editor.jsx").then((m) => ({ default: m.Editor }))); export { editorInstance, placeholder, setPlaceholder }; export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any; scope?: "create" | "update" | "delete" | "blob"; }) => { const navigate = useNavigate(); const params = useParams(); const [openDialog, setOpenDialog] = createSignal(false); const [notice, setNotice] = createSignal(""); const [openUpload, setOpenUpload] = createSignal(false); const [openInsertMenu, setOpenInsertMenu] = createSignal(false); const [openHandleDialog, setOpenHandleDialog] = createSignal(false); const [openConfirmDialog, setOpenConfirmDialog] = createSignal(false); const hasPermission = () => !props.scope || hasUserScope(props.scope); const [isMaximized, setIsMaximized] = createSignal(false); const [isMinimized, setIsMinimized] = createSignal(false); const [collectionError, setCollectionError] = createSignal(""); const [rkeyError, setRkeyError] = createSignal(""); let blobInput!: HTMLInputElement; let formRef!: HTMLFormElement; let insertMenuRef!: HTMLDivElement; createEffect(() => { if (openInsertMenu()) { const handleClickOutside = (e: MouseEvent) => { if (insertMenuRef && !insertMenuRef.contains(e.target as Node)) { setOpenInsertMenu(false); } }; document.addEventListener("mousedown", handleClickOutside); onCleanup(() => document.removeEventListener("mousedown", handleClickOutside)); } }); onMount(() => { const keyEvent = (ev: KeyboardEvent) => { if (ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; if ((ev.target as HTMLElement).closest("[data-modal]")) return; const key = props.create ? "n" : "e"; if (ev.key === key) { ev.preventDefault(); if (openDialog() && isMinimized()) { setIsMinimized(false); } else if (!openDialog() && !document.querySelector("[data-modal]")) { setOpenDialog(true); } } }; window.addEventListener("keydown", keyEvent); onCleanup(() => window.removeEventListener("keydown", keyEvent)); }); const defaultPlaceholder = () => { return { $type: "app.bsky.feed.post", text: "This post was sent from PDSls", embed: { $type: "app.bsky.embed.external", external: { uri: "https://pds.ls", title: "PDSls", description: "Browse the public data on atproto", }, }, langs: ["en"], createdAt: new Date().toISOString(), }; }; createEffect(() => { if (openDialog()) { setCollectionError(""); setRkeyError(""); } }); const createRecord = async (validate: boolean | undefined) => { const formData = new FormData(formRef); const repo = formData.get("repo")?.toString(); if (!repo) return; const rpc = new Client({ handler: new OAuthUserAgent(await getSession(repo as Did)) }); const collection = formData.get("collection"); const rkey = formData.get("rkey"); let record: any; try { record = JSON.parse(editorInstance.view.state.doc.toString()); } catch (e: any) { setNotice(e.message); return; } const res = await rpc.post("com.atproto.repo.createRecord", { input: { repo: repo as Did, collection: collection ? collection.toString() : record.$type, rkey: rkey?.toString().length ? rkey?.toString() : undefined, record: record, validate: validate, }, }); if (!res.ok) { setNotice(`${res.data.error}: ${res.data.message}`); return; } setOpenConfirmDialog(false); setOpenDialog(false); const id = addNotification({ message: "Record created", type: "success", }); setTimeout(() => removeNotification(id), 3000); navigate(`/${res.data.uri}`); }; const editRecord = async (validate: boolean | undefined, recreate: boolean) => { const record = editorInstance.view.state.doc.toString(); if (!record) return; const rpc = new Client({ handler: agent()! }); try { const editedRecord = JSON.parse(record); if (recreate) { const res = await rpc.post("com.atproto.repo.applyWrites", { input: { repo: agent()!.sub, validate: validate, writes: [ { collection: params.collection as `${string}.${string}.${string}`, rkey: params.rkey!, $type: "com.atproto.repo.applyWrites#delete", }, { collection: params.collection as `${string}.${string}.${string}`, rkey: params.rkey, $type: "com.atproto.repo.applyWrites#create", value: editedRecord, }, ], }, }); if (!res.ok) { setNotice(`${res.data.error}: ${res.data.message}`); return; } } else { const res = await rpc.post("com.atproto.repo.applyWrites", { input: { repo: agent()!.sub, validate: validate, writes: [ { collection: params.collection as `${string}.${string}.${string}`, rkey: params.rkey!, $type: "com.atproto.repo.applyWrites#update", value: editedRecord, }, ], }, }); if (!res.ok) { setNotice(`${res.data.error}: ${res.data.message}`); return; } } setOpenConfirmDialog(false); setOpenDialog(false); const id = addNotification({ message: "Record edited", type: "success", }); setTimeout(() => removeNotification(id), 3000); props.refetch(); } catch (err: any) { setNotice(err.message); } }; const insertTimestamp = () => { const timestamp = new Date().toISOString(); editorInstance.view.dispatch({ changes: { from: editorInstance.view.state.selection.main.head, insert: `"${timestamp}"`, }, }); setOpenInsertMenu(false); }; const insertDidFromHandle = () => { setOpenInsertMenu(false); setOpenHandleDialog(true); }; return ( <> setOpenDialog(false)} closeOnClick={false} nonBlocking={isMinimized()} alignTop contentClass={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700 ${isMaximized() ? "w-[calc(100%-1rem)] max-w-7xl h-[85vh]" : "w-[calc(100%-1rem)] max-w-3xl h-[65vh]"} ${isMinimized() ? "hidden" : ""}`} > {props.create ? "Creating" : "Editing"} record setIsMinimized(true)} class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" > setIsMaximized(!isMaximized())} class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" > setOpenDialog(false)} class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" > at:// {(session) => ( {sessions[session].handle ?? session} )} / { const value = e.currentTarget.value; if (!value || isNsid(value)) setCollectionError(""); else setCollectionError( "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", ); }} /> / { const value = e.currentTarget.value; if (!value || isRecordKey(value)) setRkeyError(""); else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); }} /> {collectionError()} {rkeyError()} } > {notice()} setOpenInsertMenu(!openInsertMenu())}> Add { if (hasUserScope("blob")) { setOpenInsertMenu(false); blobInput.click(); } }} > Upload blob{hasUserScope("blob") ? "" : " (permission needed)"} { if (e.target.files !== null) setOpenUpload(true); }} /> setOpenUpload(false)} closeOnClick={false} contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" > setOpenUpload(false)} /> setOpenHandleDialog(false)} closeOnClick={false} contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" > setOpenHandleDialog(false)} /> setOpenConfirmDialog(false)} closeOnClick={false} contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[24rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" > { if (props.create) { createRecord(validate); } else { editRecord(validate, recreate); } }} onClose={() => setOpenConfirmDialog(false)} /> setOpenConfirmDialog(true)}> {props.create ? "Create..." : "Edit..."} setIsMinimized(false)} > {props.create ? "Creating" : "Editing"} record { if (hasPermission()) { setNotice(""); setOpenDialog(true); setIsMinimized(false); } else if (props.scope) { showPermissionPrompt(props.scope); } }} > > ); };