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