atproto explorer pdsls.dev
atproto tool

Compare changes

Choose any two refs to compare.

-10
src/utils/app-urls.ts
···
export enum App {
Bluesky,
Tangled,
-
Whitewind,
Frontpage,
Pinksea,
Linkat,
···
export const appName = {
[App.Bluesky]: "Bluesky",
[App.Tangled]: "Tangled",
-
[App.Whitewind]: "Whitewind",
[App.Frontpage]: "Frontpage",
[App.Pinksea]: "Pinksea",
[App.Linkat]: "Linkat",
···
"main.bsky.dev": App.Bluesky,
"social.daniela.lol": App.Bluesky,
"tangled.org": App.Tangled,
-
"whtwnd.com": App.Whitewind,
"frontpage.fyi": App.Frontpage,
"pinksea.art": App.Pinksea,
"linkat.blue": App.Linkat,
···
return `at://${user}`;
},
-
[App.Whitewind]: (path) => {
-
if (path.length === 2) {
-
return `at://${path[0]}/com.whtwnd.blog.entry/${path[1]}`;
-
}
-
-
return `at://${path[0]}/com.whtwnd.blog.entry`;
-
},
[App.Frontpage]: (path) => {
if (path.length === 3) {
return `at://${path[1]}/fyi.unravel.frontpage.post/${path[2]}`;
+3 -1
src/views/settings.tsx
···
import { createSignal } from "solid-js";
import { TextInput } from "../components/text-input.jsx";
+
import { ThemeSelection } from "../components/theme.jsx";
export const [hideMedia, setHideMedia] = createSignal(localStorage.hideMedia === "true");
···
<div class="flex items-center gap-1 font-semibold">
<span>Settings</span>
</div>
-
<div class="flex flex-col gap-2">
+
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-0.5">
<label for="plcDirectory" class="select-none">
PLC Directory
···
}}
/>
</div>
+
<ThemeSelection />
<div class="flex justify-between">
<div class="flex items-center gap-1">
<input
+7 -1
src/components/video-player.tsx
···
});
return (
-
<video ref={video} class="max-h-80 max-w-[20rem]" controls playsinline onLoadedData={props.onLoad}>
+
<video
+
ref={video}
+
class="max-h-80 max-w-[20rem]"
+
controls
+
playsinline
+
onLoadedData={props.onLoad}
+
>
<source type="video/mp4" />
</video>
);
+15 -15
src/utils/hooks/debounced.ts
···
-
import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js';
+
import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js";
export const createDebouncedValue = <T>(
-
accessor: Accessor<T>,
-
delay: number,
-
equals?: false | ((prev: T, next: T) => boolean),
+
accessor: Accessor<T>,
+
delay: number,
+
equals?: false | ((prev: T, next: T) => boolean),
): Accessor<T> => {
-
const initial = accessor();
-
const [state, setState] = createSignal(initial, { equals });
+
const initial = accessor();
+
const [state, setState] = createSignal(initial, { equals });
-
createEffect((prev: T) => {
-
const next = accessor();
+
createEffect((prev: T) => {
+
const next = accessor();
-
if (prev !== next) {
-
const timeout = setTimeout(() => setState(() => next), delay);
-
onCleanup(() => clearTimeout(timeout));
-
}
+
if (prev !== next) {
+
const timeout = setTimeout(() => setState(() => next), delay);
+
onCleanup(() => clearTimeout(timeout));
+
}
-
return next;
-
}, initial);
+
return next;
+
}, initial);
-
return state;
+
return state;
};
+30
src/utils/key.ts
···
+
import { parseDidKey, parsePublicMultikey } from "@atcute/crypto";
+
import { fromBase58Btc } from "@atcute/multibase";
+
+
export const detectKeyType = (key: string): string => {
+
try {
+
return parsePublicMultikey(key).type;
+
} catch (e) {
+
try {
+
const bytes = fromBase58Btc(key.startsWith("z") ? key.slice(1) : key);
+
if (bytes.length >= 2) {
+
const type = (bytes[0] << 8) | bytes[1];
+
if (type === 0xed01) {
+
return "ed25519";
+
}
+
}
+
} catch {}
+
return "unknown";
+
}
+
};
+
+
export const detectDidKeyType = (key: string): string => {
+
try {
+
return parseDidKey(key).type;
+
} catch (e) {
+
if (key.startsWith("did:key:")) {
+
return detectKeyType(key.slice(8));
+
}
+
return "unknown";
+
}
+
};
+1 -1
public/oauth-client-metadata.json
···
"client_uri": "https://pdsls.dev",
"logo_uri": "https://pdsls.dev/favicon.ico",
"redirect_uris": ["https://pdsls.dev/"],
-
"scope": "atproto transition:generic",
+
"scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
+13
src/auth/oauth-config.ts
···
+
import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client";
+
import { didDocumentResolver, handleResolver } from "../utils/api";
+
+
configureOAuth({
+
metadata: {
+
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
+
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
+
},
+
identityResolver: defaultIdentityResolver({
+
handleResolver: handleResolver,
+
didDocumentResolver: didDocumentResolver,
+
}),
+
});
+77
src/auth/scope-flow.ts
···
+
import { isDid, isHandle } from "@atcute/lexicons/syntax";
+
import { createAuthorizationUrl } from "@atcute/oauth-browser-client";
+
import { createSignal } from "solid-js";
+
+
interface UseOAuthScopeFlowOptions {
+
onError?: (error: unknown) => void;
+
onRedirecting?: () => void;
+
beforeRedirect?: (account: string) => Promise<void>;
+
}
+
+
export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => {
+
const [showScopeSelector, setShowScopeSelector] = createSignal(false);
+
const [pendingAccount, setPendingAccount] = createSignal("");
+
const [shouldForceRedirect, setShouldForceRedirect] = createSignal(false);
+
+
const initiate = (account: string) => {
+
if (!account) return;
+
setPendingAccount(account);
+
setShouldForceRedirect(false);
+
setShowScopeSelector(true);
+
};
+
+
const initiateWithRedirect = (account: string) => {
+
if (!account) return;
+
setPendingAccount(account);
+
setShouldForceRedirect(true);
+
setShowScopeSelector(true);
+
};
+
+
const complete = async (scopeString: string, scopeIds: string) => {
+
try {
+
const account = pendingAccount();
+
+
if (options.beforeRedirect && !shouldForceRedirect()) {
+
try {
+
await options.beforeRedirect(account);
+
setShowScopeSelector(false);
+
return;
+
} catch {}
+
}
+
+
localStorage.setItem("pendingScopes", scopeIds);
+
+
options.onRedirecting?.();
+
+
const authUrl = await createAuthorizationUrl({
+
scope: scopeString,
+
target:
+
isHandle(account) || isDid(account) ?
+
{ type: "account", identifier: account }
+
: { type: "pds", serviceUrl: account },
+
});
+
+
await new Promise((resolve) => setTimeout(resolve, 250));
+
location.assign(authUrl);
+
} catch (e) {
+
console.error(e);
+
options.onError?.(e);
+
setShowScopeSelector(false);
+
}
+
};
+
+
const cancel = () => {
+
setShowScopeSelector(false);
+
setPendingAccount("");
+
setShouldForceRedirect(false);
+
};
+
+
return {
+
showScopeSelector,
+
pendingAccount,
+
initiate,
+
initiateWithRedirect,
+
complete,
+
cancel,
+
};
+
};
+53
src/auth/scope-utils.ts
···
+
import { agent, sessions } from "./state";
+
+
export const GRANULAR_SCOPES = [
+
{
+
id: "create",
+
scope: "repo:*?action=create",
+
label: "Create records",
+
},
+
{
+
id: "update",
+
scope: "repo:*?action=update",
+
label: "Update records",
+
},
+
{
+
id: "delete",
+
scope: "repo:*?action=delete",
+
label: "Delete records",
+
},
+
{
+
id: "blob",
+
scope: "blob:*/*",
+
label: "Upload blobs",
+
},
+
];
+
+
export const BASE_SCOPES = ["atproto"];
+
+
export const buildScopeString = (selected: Set<string>): string => {
+
const granular = GRANULAR_SCOPES.filter((s) => selected.has(s.id)).map((s) => s.scope);
+
return [...BASE_SCOPES, ...granular].join(" ");
+
};
+
+
export const scopeIdsToString = (scopeIds: Set<string>): string => {
+
return ["atproto", ...Array.from(scopeIds)].join(",");
+
};
+
+
export const parseScopeString = (scopeIdsString: string): Set<string> => {
+
if (!scopeIdsString) return new Set();
+
const ids = scopeIdsString.split(",").filter(Boolean);
+
return new Set(ids.filter((id) => id !== "atproto"));
+
};
+
+
export const hasScope = (grantedScopes: string | undefined, scopeId: string): boolean => {
+
if (!grantedScopes) return false;
+
return grantedScopes.split(",").includes(scopeId);
+
};
+
+
export const hasUserScope = (scopeId: string): boolean => {
+
if (!agent()) return false;
+
const grantedScopes = sessions[agent()!.sub]?.grantedScopes;
+
if (!grantedScopes) return true;
+
return hasScope(grantedScopes, scopeId);
+
};
+14
src/auth/state.ts
···
+
import { OAuthUserAgent } from "@atcute/oauth-browser-client";
+
import { createSignal } from "solid-js";
+
import { createStore } from "solid-js/store";
+
+
export type Account = {
+
signedIn: boolean;
+
handle?: string;
+
grantedScopes?: string;
+
};
+
+
export type Sessions = Record<string, Account>;
+
+
export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>();
+
export const [sessions, setSessions] = createStore<Sessions>();
-170
src/components/account.tsx
···
-
import { Client, CredentialManager } from "@atcute/client";
-
import { Did } from "@atcute/lexicons";
-
import {
-
createAuthorizationUrl,
-
deleteStoredSession,
-
getSession,
-
OAuthUserAgent,
-
} from "@atcute/oauth-browser-client";
-
import { A } from "@solidjs/router";
-
import { createSignal, For, onMount, Show } from "solid-js";
-
import { createStore, produce } from "solid-js/store";
-
import { resolveDidDoc } from "../utils/api.js";
-
import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "./dropdown.jsx";
-
import { agent, Login, retrieveSession, Sessions, setAgent } from "./login.jsx";
-
import { Modal } from "./modal.jsx";
-
-
export const [sessions, setSessions] = createStore<Sessions>();
-
-
const AccountDropdown = (props: { did: Did }) => {
-
const removeSession = async (did: Did) => {
-
const currentSession = agent()?.sub;
-
try {
-
const session = await getSession(did, { allowStale: true });
-
const agent = new OAuthUserAgent(session);
-
await agent.signOut();
-
} catch {
-
deleteStoredSession(did);
-
}
-
setSessions(
-
produce((accs) => {
-
delete accs[did];
-
}),
-
);
-
localStorage.setItem("sessions", JSON.stringify(sessions));
-
if (currentSession === did) setAgent(undefined);
-
};
-
-
return (
-
<MenuProvider>
-
<DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
-
<NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" />
-
<ActionMenu
-
icon="lucide--x"
-
label="Remove account"
-
onClick={() => removeSession(props.did)}
-
/>
-
</DropdownMenu>
-
</MenuProvider>
-
);
-
};
-
-
export const AccountManager = () => {
-
const [openManager, setOpenManager] = createSignal(false);
-
const [avatars, setAvatars] = createStore<Record<Did, string>>();
-
-
onMount(async () => {
-
try {
-
await retrieveSession();
-
} catch {}
-
-
const localSessions = localStorage.getItem("sessions");
-
if (localSessions) {
-
const storedSessions: Sessions = JSON.parse(localSessions);
-
const sessionDids = Object.keys(storedSessions) as Did[];
-
sessionDids.forEach(async (did) => {
-
const doc = await resolveDidDoc(did);
-
const alias = doc.alsoKnownAs?.find((alias) => alias.startsWith("at://"));
-
if (alias) {
-
setSessions(did, {
-
signedIn: storedSessions[did].signedIn,
-
handle: alias.replace("at://", ""),
-
});
-
}
-
});
-
sessionDids.forEach(async (did) => {
-
const avatar = await getAvatar(did);
-
if (avatar) setAvatars(did, avatar);
-
});
-
}
-
});
-
-
const resumeSession = async (did: Did) => {
-
try {
-
localStorage.setItem("lastSignedIn", did);
-
await retrieveSession();
-
} catch {
-
const authUrl = await createAuthorizationUrl({
-
scope: import.meta.env.VITE_OAUTH_SCOPE,
-
target: { type: "account", identifier: did },
-
});
-
-
await new Promise((resolve) => setTimeout(resolve, 250));
-
-
location.assign(authUrl);
-
}
-
};
-
-
const getAvatar = async (did: Did) => {
-
const rpc = new Client({
-
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
-
});
-
const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } });
-
if (res.ok) {
-
return res.data.avatar;
-
}
-
return undefined;
-
};
-
-
return (
-
<>
-
<Modal open={openManager()} onClose={() => setOpenManager(false)}>
-
<div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-18 left-[50%] w-88 -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">
-
<div class="mb-2 px-1 font-semibold">
-
<span>Manage accounts</span>
-
</div>
-
<div class="mb-3 max-h-80 overflow-y-auto md:max-h-100">
-
<For each={Object.keys(sessions)}>
-
{(did) => (
-
<div class="flex w-full items-center justify-between">
-
<A
-
href={`/at://${did}`}
-
onClick={() => setOpenManager(false)}
-
class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
>
-
<Show
-
when={avatars[did as Did]}
-
fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>}
-
>
-
<img
-
src={avatars[did as Did].replace("img/avatar/", "img/avatar_thumbnail/")}
-
class="size-6 rounded-full"
-
/>
-
</Show>
-
</A>
-
<button
-
class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
-
onclick={() => resumeSession(did as Did)}
-
>
-
<span class="truncate">
-
{sessions[did]?.handle ? sessions[did].handle : did}
-
</span>
-
<Show when={did === agent()?.sub && sessions[did].signedIn}>
-
<span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span>
-
</Show>
-
<Show when={!sessions[did].signedIn}>
-
<span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span>
-
</Show>
-
</button>
-
<AccountDropdown did={did as Did} />
-
</div>
-
)}
-
</For>
-
</div>
-
<Login />
-
</div>
-
</Modal>
-
<button
-
onclick={() => setOpenManager(true)}
-
class={`flex items-center rounded-lg ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`}
-
>
-
{agent() && avatars[agent()!.sub] ?
-
<img
-
src={avatars[agent()!.sub].replace("img/avatar/", "img/avatar_thumbnail/")}
-
class="size-5 rounded-full"
-
/>
-
: <span class="iconify lucide--circle-user-round text-lg"></span>}
-
</button>
-
</>
-
);
-
};
+2 -2
src/views/pds.tsx
···
import { ComAtprotoServerDescribeServer, ComAtprotoSyncListRepos } from "@atcute/atproto";
-
import { Client, CredentialManager } from "@atcute/client";
+
import { Client, simpleFetchHandler } from "@atcute/client";
import { InferXRPCBodyOutput } from "@atcute/lexicons";
import * as TID from "@atcute/tid";
import { A, useLocation, useParams } from "@solidjs/router";
···
setPDS(params.pds);
const pds =
params.pds!.startsWith("localhost") ? `http://${params.pds}` : `https://${params.pds}`;
-
const rpc = new Client({ handler: new CredentialManager({ service: pds }) });
+
const rpc = new Client({ handler: simpleFetchHandler({ service: pds }) });
const getVersion = async () => {
// @ts-expect-error: undocumented endpoint
public/favicon.ico

This is a binary file and will not be displayed.

public/fonts/Figtree[wght].woff2

This is a binary file and will not be displayed.

+25 -5
src/components/search.tsx
···
import { Client, simpleFetchHandler } from "@atcute/client";
import { Nsid } from "@atcute/lexicons";
-
import { A, useLocation, useNavigate } from "@solidjs/router";
-
import { createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
+
import { A, useNavigate } from "@solidjs/router";
+
import {
+
createEffect,
+
createResource,
+
createSignal,
+
For,
+
onCleanup,
+
onMount,
+
Show,
+
} from "solid-js";
import { isTouchDevice } from "../layout";
import { resolveLexiconAuthority } from "../utils/api";
import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls";
···
if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") {
ev.preventDefault();
-
setShowSearch(!showSearch());
+
+
if (showSearch()) {
+
const searchInput = document.querySelector("#input") as HTMLInputElement;
+
if (searchInput && document.activeElement !== searchInput) {
+
searchInput.focus();
+
} else {
+
setShowSearch(false);
+
}
+
} else {
+
setShowSearch(true);
+
}
} else if (ev.key == "Escape") {
ev.preventDefault();
setShowSearch(false);
···
});
onMount(() => {
-
if (useLocation().pathname !== "/") searchInput.focus();
-
const handlePaste = (e: ClipboardEvent) => {
if (e.target === searchInput) return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
···
onCleanup(() => window.removeEventListener("paste", handlePaste));
});
+
createEffect(() => {
+
if (showSearch()) searchInput.focus();
+
});
+
const fetchTypeahead = async (input: string) => {
const { prefix, query } = parsePrefix(input);
+5 -1
src/auth/account.tsx
···
return (
<MenuProvider>
<DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2">
-
<NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" />
+
<NavMenu
+
href={`/at://${props.did}`}
+
label={agent()?.sub === props.did ? "Go to repo (g)" : "Go to repo"}
+
icon="lucide--user-round"
+
/>
<ActionMenu
icon="lucide--settings"
label="Edit permissions"
+109
src/components/create/file-upload.tsx
···
+
import { Client } from "@atcute/client";
+
import { remove } from "@mary/exif-rm";
+
import { createSignal, onCleanup, Show } from "solid-js";
+
import { agent } from "../../auth/state";
+
import { Button } from "../button.jsx";
+
import { TextInput } from "../text-input.jsx";
+
import { editorInstance } from "./state";
+
+
export const FileUpload = (props: {
+
file: File;
+
blobInput: HTMLInputElement;
+
onClose: () => void;
+
}) => {
+
const [uploading, setUploading] = createSignal(false);
+
const [error, setError] = createSignal("");
+
+
onCleanup(() => (props.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),
+
},
+
});
+
props.onClose();
+
};
+
+
return (
+
<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">
+
<h2 class="mb-2 font-semibold">Upload blob</h2>
+
<div class="flex flex-col gap-2 text-sm">
+
<div class="flex flex-col gap-1">
+
<p class="flex gap-1">
+
<span class="truncate">{props.file.name}</span>
+
<span class="shrink-0 text-neutral-600 dark:text-neutral-400">
+
({formatFileSize(props.file.size)})
+
</span>
+
</p>
+
</div>
+
<div class="flex items-center gap-x-2">
+
<label for="mimetype" class="shrink-0 select-none">
+
MIME type
+
</label>
+
<TextInput id="mimetype" placeholder={props.file.type} />
+
</div>
+
<div class="flex items-center gap-1">
+
<input id="exif-rm" type="checkbox" checked />
+
<label for="exif-rm" class="select-none">
+
Remove EXIF data
+
</label>
+
</div>
+
<p class="text-xs text-neutral-600 dark:text-neutral-400">
+
Metadata will be pasted after the cursor
+
</p>
+
<Show when={error()}>
+
<span class="text-red-500 dark:text-red-400">Error: {error()}</span>
+
</Show>
+
<div class="flex justify-between gap-2">
+
<Button onClick={props.onClose}>Cancel</Button>
+
<Show when={uploading()}>
+
<div class="flex items-center gap-1">
+
<span class="iconify lucide--loader-circle animate-spin"></span>
+
<span>Uploading</span>
+
</div>
+
</Show>
+
<Show when={!uploading()}>
+
<Button
+
onClick={uploadBlob}
+
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"
+
>
+
Upload
+
</Button>
+
</Show>
+
</div>
+
</div>
+
</div>
+
);
+
};
+87
src/components/create/handle-input.tsx
···
+
import { Handle } from "@atcute/lexicons";
+
import { createSignal, Show } from "solid-js";
+
import { resolveHandle } from "../../utils/api";
+
import { Button } from "../button.jsx";
+
import { TextInput } from "../text-input.jsx";
+
import { editorInstance } from "./state";
+
+
export const HandleInput = (props: { onClose: () => void }) => {
+
const [resolving, setResolving] = createSignal(false);
+
const [error, setError] = createSignal("");
+
let handleFormRef!: HTMLFormElement;
+
+
const resolveDid = async (e: SubmitEvent) => {
+
e.preventDefault();
+
const formData = new FormData(handleFormRef);
+
const handleValue = formData.get("handle")?.toString().trim();
+
+
if (!handleValue) {
+
setError("Please enter a handle");
+
return;
+
}
+
+
setResolving(true);
+
setError("");
+
try {
+
const did = await resolveHandle(handleValue as Handle);
+
editorInstance.view.dispatch({
+
changes: {
+
from: editorInstance.view.state.selection.main.head,
+
insert: `"${did}"`,
+
},
+
});
+
props.onClose();
+
handleFormRef.reset();
+
} catch (err: any) {
+
setError(err.message || "Failed to resolve handle");
+
} finally {
+
setResolving(false);
+
}
+
};
+
+
return (
+
<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">
+
<h2 class="mb-2 font-semibold">Insert DID from handle</h2>
+
<form ref={handleFormRef} onSubmit={resolveDid} class="flex flex-col gap-2 text-sm">
+
<div class="flex flex-col gap-1">
+
<label for="handle-input" class="select-none">
+
Handle
+
</label>
+
<TextInput id="handle-input" name="handle" placeholder="user.bsky.social" />
+
</div>
+
<p class="text-xs text-neutral-600 dark:text-neutral-400">
+
DID will be pasted after the cursor
+
</p>
+
<Show when={error()}>
+
<span class="text-red-500 dark:text-red-400">Error: {error()}</span>
+
</Show>
+
<div class="flex justify-between gap-2">
+
<Button
+
type="button"
+
onClick={() => {
+
props.onClose();
+
handleFormRef.reset();
+
setError("");
+
}}
+
>
+
Cancel
+
</Button>
+
<Show when={resolving()}>
+
<div class="flex items-center gap-1">
+
<span class="iconify lucide--loader-circle animate-spin"></span>
+
<span>Resolving</span>
+
</div>
+
</Show>
+
<Show when={!resolving()}>
+
<Button
+
type="submit"
+
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"
+
>
+
Insert
+
</Button>
+
</Show>
+
</div>
+
</form>
+
</div>
+
);
+
};
+12
src/components/create/menu-item.tsx
···
+
export const MenuItem = (props: { icon: string; label: string; onClick: () => void }) => {
+
return (
+
<button
+
type="button"
+
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"
+
onClick={props.onClick}
+
>
+
<span class={`iconify ${props.icon}`}></span>
+
<span>{props.label}</span>
+
</button>
+
);
+
};
+4
src/components/create/state.ts
···
+
import { createSignal } from "solid-js";
+
+
export const editorInstance = { view: null as any };
+
export const [placeholder, setPlaceholder] = createSignal<any>();