forked from pdsls.dev/pdsls
this repo has no description

add typeahead suggestions

juli.ee f0b2461d ddf7050c

verified
Changed files
+91 -29
src
components
utils
hooks
+14 -22
src/components/account.tsx
··· 1 1 import { Client, CredentialManager } from "@atcute/client"; 2 2 import { Did } from "@atcute/lexicons"; 3 3 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 4 - import { A } from "@solidjs/router"; 5 4 import { createSignal, For, onMount, Show } from "solid-js"; 6 5 import { createStore } from "solid-js/store"; 7 6 import { resolveDidDoc } from "../utils/api.js"; ··· 76 75 <div class="mb-3 max-h-[20rem] overflow-y-auto md:max-h-[25rem]"> 77 76 <For each={Object.keys(sessions)}> 78 77 {(did) => ( 79 - <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"> 80 - <button 81 - class="flex basis-full items-center justify-between gap-1 truncate p-1" 82 - onclick={() => resumeSession(did as Did)} 83 - > 84 - <span class="truncate">{sessions[did]?.length ? sessions[did] : did}</span> 85 - <Show when={did === agent()?.sub}> 86 - <span class="iconify lucide--check shrink-0"></span> 87 - </Show> 88 - </button> 89 - <div class="flex items-center gap-1"> 90 - <A 91 - href={`/at://${did}`} 92 - onClick={() => setOpenManager(false)} 93 - class="flex items-center p-1" 94 - > 95 - <span class="iconify lucide--book-user"></span> 96 - </A> 78 + <div class="flex items-center gap-1"> 79 + <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"> 97 80 <button 98 - onclick={() => removeSession(did as Did)} 99 - class="flex items-center p-1 hover:text-red-500 hover:dark:text-red-400" 81 + class="flex basis-full items-center justify-between gap-1 truncate p-1" 82 + onclick={() => resumeSession(did as Did)} 100 83 > 101 - <span class="iconify lucide--user-round-x"></span> 84 + <span class="truncate">{sessions[did]?.length ? sessions[did] : did}</span> 85 + <Show when={did === agent()?.sub}> 86 + <span class="iconify lucide--check shrink-0"></span> 87 + </Show> 102 88 </button> 103 89 </div> 90 + <button 91 + onclick={() => removeSession(did as Did)} 92 + class="flex items-center p-1 hover:text-red-500 hover:dark:text-red-400" 93 + > 94 + <span class="iconify lucide--user-round-x"></span> 95 + </button> 104 96 </div> 105 97 )} 106 98 </For>
+54 -7
src/components/search.tsx
··· 1 - import { useLocation, useNavigate } from "@solidjs/router"; 2 - import { createSignal, onCleanup, onMount, Show } from "solid-js"; 1 + import { Client, CredentialManager } from "@atcute/client"; 2 + import { A, useLocation, useNavigate } from "@solidjs/router"; 3 + import { createResource, createSignal, For, onCleanup, onMount, Show, Suspense } from "solid-js"; 3 4 import { isTouchDevice } from "../layout"; 5 + import { createDebouncedValue } from "../utils/hooks/debounced"; 4 6 5 7 export const [showSearch, setShowSearch] = createSignal(false); 6 8 ··· 38 40 const Search = () => { 39 41 const navigate = useNavigate(); 40 42 let searchInput!: HTMLInputElement; 43 + const rpc = new Client({ 44 + handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 45 + }); 41 46 42 47 onMount(() => { 43 48 if (useLocation().pathname !== "/") searchInput.focus(); 44 49 }); 45 50 51 + const fetchTypeahead = async (input: string) => { 52 + if (!input.length) return []; 53 + const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { 54 + params: { q: input, limit: 5 }, 55 + }); 56 + if (res.ok) { 57 + return res.data.actors; 58 + } 59 + return []; 60 + }; 61 + 62 + const [input, setInput] = createSignal<string>(); 63 + const [search] = createResource(createDebouncedValue(input, 300), fetchTypeahead); 64 + 46 65 const processInput = (input: string) => { 47 66 input = input.trim().replace(/^@/, ""); 48 67 if (!input.length) return; ··· 54 73 (input.startsWith("https://") || input.startsWith("http://")) 55 74 ) { 56 75 navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`); 76 + } else if (search()?.length) { 77 + navigate(`/at://${search()![0].did}`); 57 78 } else { 58 79 const uri = input 59 80 .replace("at://", "") ··· 64 85 `/at://${uriParts[0]}${uriParts.length > 1 ? `/${uriParts.slice(1).join("/")}` : ""}`, 65 86 ); 66 87 } 88 + setShowSearch(false); 67 89 }; 68 90 69 91 return ( 70 92 <form 71 - class="w-[22rem] sm:w-[24rem]" 93 + class="relative w-[22rem] sm:w-[24rem]" 72 94 onsubmit={(e) => { 73 95 e.preventDefault(); 74 96 processInput(searchInput.value); ··· 85 107 ref={searchInput} 86 108 id="input" 87 109 class="grow select-none placeholder:text-sm focus:outline-none" 110 + value={input() ?? ""} 111 + onInput={(e) => setInput(e.currentTarget.value)} 88 112 /> 89 - <button 90 - type="submit" 91 - class="iconify lucide--arrow-right text-lg text-neutral-500 dark:text-neutral-400" 92 - ></button> 113 + <Show when={input()}> 114 + <button 115 + type="button" 116 + 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" 117 + onClick={() => setInput(undefined)} 118 + > 119 + <span class="iconify lucide--x text-lg"></span> 120 + </button> 121 + </Show> 93 122 </div> 123 + <Show when={search()?.length && input()}> 124 + <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"> 125 + <Suspense fallback={<div class="p-1">Loading...</div>}> 126 + <For each={search()}> 127 + {(actor) => ( 128 + <A 129 + class="flex items-center gap-2 rounded-lg p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700" 130 + href={`/at://${actor.did}`} 131 + onClick={() => setShowSearch(false)} 132 + > 133 + <img src={actor.avatar} class="size-6 rounded-full" /> 134 + <span>{actor.handle}</span> 135 + </A> 136 + )} 137 + </For> 138 + </Suspense> 139 + </div> 140 + </Show> 94 141 </form> 95 142 ); 96 143 };
+23
src/utils/hooks/debounced.ts
··· 1 + import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js'; 2 + 3 + export const createDebouncedValue = <T>( 4 + accessor: Accessor<T>, 5 + delay: number, 6 + equals?: false | ((prev: T, next: T) => boolean), 7 + ): Accessor<T> => { 8 + const initial = accessor(); 9 + const [state, setState] = createSignal(initial, { equals }); 10 + 11 + createEffect((prev: T) => { 12 + const next = accessor(); 13 + 14 + if (prev !== next) { 15 + const timeout = setTimeout(() => setState(() => next), delay); 16 + onCleanup(() => clearTimeout(timeout)); 17 + } 18 + 19 + return next; 20 + }, initial); 21 + 22 + return state; 23 + };