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 { remove } from "@mary/exif-rm"; 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 { TextInput } from "./text-input.jsx"; import Tooltip from "./tooltip.jsx"; const Editor = lazy(() => import("../components/editor.jsx").then((m) => ({ default: m.Editor }))); export const editorInstance = { view: null as any }; export const [placeholder, setPlaceholder] = createSignal(); export const RecordEditor = (props: { create: boolean; record?: any; refetch?: any }) => { 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 [validate, setValidate] = createSignal(undefined); 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; 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://pdsls.dev", title: "PDSls", description: "Browse the public data on atproto", }, }, langs: ["en"], createdAt: new Date().toISOString(), }; }; const getValidateIcon = () => { return ( validate() === true ? "lucide--circle-check" : validate() === false ? "lucide--circle-x" : "lucide--circle" ); }; const getValidateLabel = () => { return ( validate() === true ? "True" : validate() === false ? "False" : "Unset" ); }; createEffect(() => { if (openDialog()) { setValidate(undefined); setCollectionError(""); setRkeyError(""); } }); const createRecord = async (formData: FormData) => { 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; } setOpenDialog(false); const id = addNotification({ message: "Record created", type: "success", }); setTimeout(() => removeNotification(id), 3000); navigate(`/${res.data.uri}`); }; const editRecord = async (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; } } 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 MenuItem = (props: { icon: string; label: string; onClick: () => void }) => { return ( ); }; const FileUpload = (props: { file: File }) => { const [uploading, setUploading] = createSignal(false); const [error, setError] = createSignal(""); onCleanup(() => (blobInput.value = "")); const formatFileSize = (bytes: number) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; }; const uploadBlob = async () => { let blob: Blob; const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; (document.getElementById("mimetype") as HTMLInputElement).value = ""; if (mimetype) blob = new Blob([props.file], { type: mimetype }); else blob = props.file; if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); } const rpc = new Client({ handler: agent()! }); setUploading(true); const res = await rpc.post("com.atproto.repo.uploadBlob", { input: blob, }); setUploading(false); if (!res.ok) { setError(res.data.error); return; } editorInstance.view.dispatch({ changes: { from: editorInstance.view.state.selection.main.head, insert: JSON.stringify(res.data.blob, null, 2), }, }); setOpenUpload(false); }; return (

Upload blob

{props.file.name} ({formatFileSize(props.file.size)})

Metadata will be pasted after the cursor

Error: {error()}
Uploading
); }; return ( <> setOpenDialog(false)} closeOnClick={false} nonBlocking={isMinimized()} >
{props.create ? "Creating" : "Editing"} record
at:// / { 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(false); blobInput.click(); }} />
{ if (e.target.files !== null) setOpenUpload(true); }} />
setOpenUpload(false)} closeOnClick={false} >
); };