atproto explorer pdsls.dev
atproto tool

add typeahead suggestions

juli.ee f0b2461d ddf7050c

verified
Changed files
+91 -29
src
components
utils
hooks
+14 -22
src/components/account.tsx
···
import { Client, CredentialManager } from "@atcute/client";
import { Did } from "@atcute/lexicons";
import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client";
-
import { A } from "@solidjs/router";
import { createSignal, For, onMount, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { resolveDidDoc } from "../utils/api.js";
···
<div class="mb-3 max-h-[20rem] overflow-y-auto md:max-h-[25rem]">
<For each={Object.keys(sessions)}>
{(did) => (
-
<div class="flex w-full items-center justify-between gap-x-2 rounded-lg hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600">
-
<button
-
class="flex basis-full items-center justify-between gap-1 truncate p-1"
-
onclick={() => resumeSession(did as Did)}
-
>
-
<span class="truncate">{sessions[did]?.length ? sessions[did] : did}</span>
-
<Show when={did === agent()?.sub}>
-
<span class="iconify lucide--check shrink-0"></span>
-
</Show>
-
</button>
-
<div class="flex items-center gap-1">
-
<A
-
href={`/at://${did}`}
-
onClick={() => setOpenManager(false)}
-
class="flex items-center p-1"
-
>
-
<span class="iconify lucide--book-user"></span>
-
</A>
+
<div class="flex items-center gap-1">
+
<div class="flex w-full items-center justify-between gap-x-2 rounded-lg hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600">
<button
-
onclick={() => removeSession(did as Did)}
-
class="flex items-center p-1 hover:text-red-500 hover:dark:text-red-400"
+
class="flex basis-full items-center justify-between gap-1 truncate p-1"
+
onclick={() => resumeSession(did as Did)}
>
-
<span class="iconify lucide--user-round-x"></span>
+
<span class="truncate">{sessions[did]?.length ? sessions[did] : did}</span>
+
<Show when={did === agent()?.sub}>
+
<span class="iconify lucide--check shrink-0"></span>
+
</Show>
</button>
</div>
+
<button
+
onclick={() => removeSession(did as Did)}
+
class="flex items-center p-1 hover:text-red-500 hover:dark:text-red-400"
+
>
+
<span class="iconify lucide--user-round-x"></span>
+
</button>
</div>
)}
</For>
+54 -7
src/components/search.tsx
···
-
import { useLocation, useNavigate } from "@solidjs/router";
-
import { createSignal, onCleanup, onMount, Show } from "solid-js";
+
import { Client, CredentialManager } from "@atcute/client";
+
import { A, useLocation, useNavigate } from "@solidjs/router";
+
import { createResource, createSignal, For, onCleanup, onMount, Show, Suspense } from "solid-js";
import { isTouchDevice } from "../layout";
+
import { createDebouncedValue } from "../utils/hooks/debounced";
export const [showSearch, setShowSearch] = createSignal(false);
···
const Search = () => {
const navigate = useNavigate();
let searchInput!: HTMLInputElement;
+
const rpc = new Client({
+
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
+
});
onMount(() => {
if (useLocation().pathname !== "/") searchInput.focus();
});
+
const fetchTypeahead = async (input: string) => {
+
if (!input.length) return [];
+
const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", {
+
params: { q: input, limit: 5 },
+
});
+
if (res.ok) {
+
return res.data.actors;
+
}
+
return [];
+
};
+
+
const [input, setInput] = createSignal<string>();
+
const [search] = createResource(createDebouncedValue(input, 300), fetchTypeahead);
+
const processInput = (input: string) => {
input = input.trim().replace(/^@/, "");
if (!input.length) return;
···
(input.startsWith("https://") || input.startsWith("http://"))
) {
navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`);
+
} else if (search()?.length) {
+
navigate(`/at://${search()![0].did}`);
} else {
const uri = input
.replace("at://", "")
···
`/at://${uriParts[0]}${uriParts.length > 1 ? `/${uriParts.slice(1).join("/")}` : ""}`,
);
}
+
setShowSearch(false);
};
return (
<form
-
class="w-[22rem] sm:w-[24rem]"
+
class="relative w-[22rem] sm:w-[24rem]"
onsubmit={(e) => {
e.preventDefault();
processInput(searchInput.value);
···
ref={searchInput}
id="input"
class="grow select-none placeholder:text-sm focus:outline-none"
+
value={input() ?? ""}
+
onInput={(e) => setInput(e.currentTarget.value)}
/>
-
<button
-
type="submit"
-
class="iconify lucide--arrow-right text-lg text-neutral-500 dark:text-neutral-400"
-
></button>
+
<Show when={input()}>
+
<button
+
type="button"
+
class="flex items-center rounded-lg p-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500"
+
onClick={() => setInput(undefined)}
+
>
+
<span class="iconify lucide--x text-lg"></span>
+
</button>
+
</Show>
</div>
+
<Show when={search()?.length && input()}>
+
<div class="dark:bg-dark-300 absolute z-30 mt-2 flex w-full flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1 dark:border-neutral-700">
+
<Suspense fallback={<div class="p-1">Loading...</div>}>
+
<For each={search()}>
+
{(actor) => (
+
<A
+
class="flex items-center gap-2 rounded-lg p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700"
+
href={`/at://${actor.did}`}
+
onClick={() => setShowSearch(false)}
+
>
+
<img src={actor.avatar} class="size-6 rounded-full" />
+
<span>{actor.handle}</span>
+
</A>
+
)}
+
</For>
+
</Suspense>
+
</div>
+
</Show>
</form>
);
};
+23
src/utils/hooks/debounced.ts
···
+
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<T> => {
+
const initial = accessor();
+
const [state, setState] = createSignal(initial, { equals });
+
+
createEffect((prev: T) => {
+
const next = accessor();
+
+
if (prev !== next) {
+
const timeout = setTimeout(() => setState(() => next), delay);
+
onCleanup(() => clearTimeout(timeout));
+
}
+
+
return next;
+
}, initial);
+
+
return state;
+
};