atproto explorer pdsls.dev
atproto tool
at v1.0.0 14 kB view raw
1import { Client, CredentialManager } from "@atcute/client"; 2import { Nsid } from "@atcute/lexicons"; 3import { A, useLocation, useNavigate } from "@solidjs/router"; 4import { createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5import { isTouchDevice } from "../layout"; 6import { resolveLexiconAuthority } from "../utils/api"; 7import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls"; 8import { createDebouncedValue } from "../utils/hooks/debounced"; 9import { Modal } from "./modal"; 10 11export const [showSearch, setShowSearch] = createSignal(false); 12 13const SEARCH_PREFIXES: { prefix: string; description: string }[] = [ 14 { prefix: "@", description: "example.com" }, 15 { prefix: "did:", description: "web:example.com" }, 16 { prefix: "at:", description: "//example.com/com.example.test/self" }, 17 { prefix: "lex:", description: "com.example.test" }, 18 { prefix: "pds:", description: "host.example.com" }, 19]; 20 21const parsePrefix = (input: string): { prefix: string | null; query: string } => { 22 const matchedPrefix = SEARCH_PREFIXES.find((p) => input.startsWith(p.prefix)); 23 if (matchedPrefix) { 24 return { 25 prefix: matchedPrefix.prefix, 26 query: input.slice(matchedPrefix.prefix.length), 27 }; 28 } 29 return { prefix: null, query: input }; 30}; 31 32const SearchButton = () => { 33 onMount(() => window.addEventListener("keydown", keyEvent)); 34 onCleanup(() => window.removeEventListener("keydown", keyEvent)); 35 36 const keyEvent = (ev: KeyboardEvent) => { 37 if (document.querySelector("[data-modal]")) return; 38 39 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") { 40 ev.preventDefault(); 41 setShowSearch(!showSearch()); 42 } else if (ev.key == "Escape") { 43 ev.preventDefault(); 44 setShowSearch(false); 45 } 46 }; 47 48 return ( 49 <button 50 onclick={() => setShowSearch(!showSearch())} 51 class="dark:bg-dark-100/70 text-baseline mr-1 box-border flex h-7 items-center gap-1 rounded-md border-[0.5px] border-neutral-300 bg-neutral-100/70 px-2 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 52 > 53 <span class="iconify lucide--search"></span> 54 <Show 55 when={!isTouchDevice} 56 fallback={<span class="text-neutral-500 dark:text-neutral-400">Search</span>} 57 > 58 <kbd class="font-sans leading-none text-neutral-500 select-none dark:text-neutral-400"> 59 {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K 60 </kbd> 61 </Show> 62 </button> 63 ); 64}; 65 66const Search = () => { 67 const navigate = useNavigate(); 68 let searchInput!: HTMLInputElement; 69 const rpc = new Client({ 70 handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 71 }); 72 73 onMount(() => { 74 if (useLocation().pathname !== "/") searchInput.focus(); 75 76 const handlePaste = (e: ClipboardEvent) => { 77 if (e.target === searchInput) return; 78 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 79 80 const pastedText = e.clipboardData?.getData("text"); 81 if (pastedText) processInput(pastedText); 82 }; 83 84 window.addEventListener("paste", handlePaste); 85 onCleanup(() => window.removeEventListener("paste", handlePaste)); 86 }); 87 88 const fetchTypeahead = async (input: string) => { 89 const { prefix, query } = parsePrefix(input); 90 91 if (prefix === "@") { 92 if (!query.length) return []; 93 94 const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { 95 params: { q: query, limit: 5 }, 96 }); 97 if (res.ok) { 98 return res.data.actors; 99 } 100 } 101 102 return []; 103 }; 104 105 const [input, setInput] = createSignal<string>(); 106 const [selectedIndex, setSelectedIndex] = createSignal(-1); 107 const [isFocused, setIsFocused] = createSignal(false); 108 const [search] = createResource(createDebouncedValue(input, 200), fetchTypeahead); 109 110 const getPrefixSuggestions = () => { 111 const currentInput = input(); 112 if (!currentInput) return SEARCH_PREFIXES; 113 114 const { prefix } = parsePrefix(currentInput); 115 if (prefix) return []; 116 117 return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase())); 118 }; 119 120 const processInput = async (input: string) => { 121 input = input.trim().replace(/^@/, ""); 122 if (!input.length) return; 123 124 setShowSearch(false); 125 setInput(undefined); 126 setSelectedIndex(-1); 127 128 const { prefix, query } = parsePrefix(input); 129 130 if (prefix === "@") { 131 navigate(`/at://${query}`); 132 } else if (prefix === "did:") { 133 navigate(`/at://did:${query}`); 134 } else if (prefix === "at:") { 135 navigate(`/${input}`); 136 } else if (prefix === "lex:") { 137 const nsid = query as Nsid; 138 const res = await resolveLexiconAuthority(nsid); 139 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); 140 } else if (prefix === "pds:") { 141 navigate(`/${query}`); 142 } else if (input.startsWith("https://") || input.startsWith("http://")) { 143 const hostLength = input.indexOf("/", 8); 144 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); 145 146 if (!(host in appList)) { 147 navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`); 148 } else { 149 const app = appList[host as AppUrl]; 150 const path = input.slice(hostLength + 1).split("/"); 151 152 const uri = appHandleLink[app](path); 153 navigate(`/${uri}`); 154 } 155 } else { 156 navigate(`/at://${input.replace("at://", "")}`); 157 } 158 }; 159 160 return ( 161 <form 162 class="relative w-full" 163 onsubmit={(e) => { 164 e.preventDefault(); 165 processInput(searchInput.value); 166 }} 167 > 168 <label for="input" class="hidden"> 169 PDS URL, AT URI, NSID, DID, or handle 170 </label> 171 <div class="dark:bg-dark-100 dark:inset-shadow-dark-200 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 inset-shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400"> 172 <label 173 for="input" 174 class="iconify lucide--search text-neutral-500 dark:text-neutral-400" 175 ></label> 176 <input 177 type="text" 178 spellcheck={false} 179 placeholder="Handle, DID, AT URI, NSID, PDS" 180 ref={searchInput} 181 id="input" 182 class="grow py-1 select-none placeholder:text-sm focus:outline-none" 183 value={input() ?? ""} 184 onInput={(e) => { 185 setInput(e.currentTarget.value); 186 setSelectedIndex(-1); 187 }} 188 onFocus={() => setIsFocused(true)} 189 onBlur={() => { 190 setSelectedIndex(-1); 191 setIsFocused(false); 192 }} 193 onKeyDown={(e) => { 194 const results = search(); 195 const prefixSuggestions = getPrefixSuggestions(); 196 const totalSuggestions = (prefixSuggestions.length || 0) + (results?.length || 0); 197 198 if (!totalSuggestions) return; 199 200 if (e.key === "ArrowDown") { 201 e.preventDefault(); 202 setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % totalSuggestions)); 203 } else if (e.key === "ArrowUp") { 204 e.preventDefault(); 205 setSelectedIndex((prev) => 206 prev === -1 ? 207 totalSuggestions - 1 208 : (prev - 1 + totalSuggestions) % totalSuggestions, 209 ); 210 } else if (e.key === "Enter") { 211 const index = selectedIndex(); 212 if (index >= 0) { 213 e.preventDefault(); 214 if (index < prefixSuggestions.length) { 215 const selectedPrefix = prefixSuggestions[index]; 216 setInput(selectedPrefix.prefix); 217 setSelectedIndex(-1); 218 searchInput.focus(); 219 } else { 220 const adjustedIndex = index - prefixSuggestions.length; 221 if (results && results[adjustedIndex]) { 222 setShowSearch(false); 223 setInput(undefined); 224 navigate(`/at://${results[adjustedIndex].did}`); 225 setSelectedIndex(-1); 226 } 227 } 228 } else if (results?.length && prefixSuggestions.length === 0) { 229 e.preventDefault(); 230 setShowSearch(false); 231 setInput(undefined); 232 navigate(`/at://${results[0].did}`); 233 setSelectedIndex(-1); 234 } 235 } 236 }} 237 /> 238 <Show when={input()} fallback={ListUrlsTooltip()}> 239 <button 240 type="button" 241 class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 242 onClick={() => setInput(undefined)} 243 > 244 <span class="iconify lucide--x"></span> 245 </button> 246 </Show> 247 </div> 248 <Show when={isFocused() && (getPrefixSuggestions().length > 0 || search()?.length)}> 249 <div 250 class="dark:bg-dark-300 dark:shadow-dark-700 absolute z-30 mt-1 flex w-full flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0" 251 onMouseDown={(e) => e.preventDefault()} 252 > 253 {/* Prefix suggestions */} 254 <For each={getPrefixSuggestions()}> 255 {(prefixItem, index) => ( 256 <button 257 type="button" 258 class={`flex items-center rounded-lg p-2 ${ 259 index() === selectedIndex() ? 260 "bg-neutral-200 dark:bg-neutral-700" 261 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 262 }`} 263 onClick={() => { 264 setInput(prefixItem.prefix); 265 setSelectedIndex(-1); 266 searchInput.focus(); 267 }} 268 > 269 <span class={`text-sm font-semibold`}>{prefixItem.prefix}</span> 270 <span class="text-sm text-neutral-600 dark:text-neutral-400"> 271 {prefixItem.description} 272 </span> 273 </button> 274 )} 275 </For> 276 277 {/* Typeahead results */} 278 <For each={search()}> 279 {(actor, index) => { 280 const adjustedIndex = getPrefixSuggestions().length + index(); 281 return ( 282 <A 283 class={`flex items-center gap-2 rounded-lg p-2 ${ 284 adjustedIndex === selectedIndex() ? 285 "bg-neutral-200 dark:bg-neutral-700" 286 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 287 }`} 288 href={`/at://${actor.did}`} 289 onClick={() => setShowSearch(false)} 290 > 291 <img 292 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} 293 class="size-8 rounded-full" 294 /> 295 <span>{actor.handle}</span> 296 </A> 297 ); 298 }} 299 </For> 300 </div> 301 </Show> 302 </form> 303 ); 304}; 305 306const ListUrlsTooltip = () => { 307 const [openList, setOpenList] = createSignal(false); 308 309 let urls: Record<string, AppUrl[]> = {}; 310 for (const [appUrl, appView] of Object.entries(appList)) { 311 if (!urls[appView]) urls[appView] = [appUrl as AppUrl]; 312 else urls[appView].push(appUrl as AppUrl); 313 } 314 315 return ( 316 <> 317 <Modal open={openList()} onClose={() => setOpenList(false)}> 318 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-16 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 sm:w-104 dark:border-neutral-700 starting:opacity-0"> 319 <div class="mb-2 flex items-center gap-1 font-semibold"> 320 <span class="iconify lucide--link"></span> 321 <span>Supported URLs</span> 322 </div> 323 <div class="mb-2 text-sm text-neutral-600 dark:text-neutral-400"> 324 Links that will be parsed automatically, as long as all the data necessary is on the 325 URL. 326 </div> 327 <div class="flex flex-col gap-2 text-sm"> 328 <For each={Object.entries(appName)}> 329 {([appView, name]) => { 330 return ( 331 <div> 332 <p class="font-semibold">{name}</p> 333 <div class="grid grid-cols-2 gap-x-4 text-neutral-600 dark:text-neutral-400"> 334 <For each={urls[appView]}> 335 {(url) => ( 336 <a 337 href={`${url.startsWith("localhost:") ? "http://" : "https://"}${url}`} 338 target="_blank" 339 class="hover:underline active:underline" 340 > 341 {url} 342 </a> 343 )} 344 </For> 345 </div> 346 </div> 347 ); 348 }} 349 </For> 350 </div> 351 </div> 352 </Modal> 353 <button 354 type="button" 355 class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 356 onClick={() => setOpenList(true)} 357 > 358 <span class="iconify lucide--help-circle"></span> 359 </button> 360 </> 361 ); 362}; 363 364export { Search, SearchButton };