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