atmosphere explorer
at main 474 lines 17 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 { canHover } from "../layout"; 14import { resolveLexiconAuthority, resolveLexiconAuthorityDirect } from "../utils/api"; 15import { appHandleLink, appList, AppUrl } from "../utils/app-urls"; 16import { createDebouncedValue } from "../utils/hooks/debounced"; 17import { Button } from "./button"; 18import { Modal } from "./modal"; 19 20type RecentSearch = { 21 path: string; 22 label: string; 23 type: "handle" | "did" | "at-uri" | "lexicon" | "pds" | "url"; 24}; 25 26const RECENT_SEARCHES_KEY = "recent-searches"; 27const MAX_RECENT_SEARCHES = 5; 28 29const getRecentSearches = (): RecentSearch[] => { 30 try { 31 const stored = localStorage.getItem(RECENT_SEARCHES_KEY); 32 return stored ? JSON.parse(stored) : []; 33 } catch { 34 return []; 35 } 36}; 37 38const addRecentSearch = (search: RecentSearch) => { 39 const searches = getRecentSearches(); 40 const filtered = searches.filter((s) => s.path !== search.path); 41 const updated = [search, ...filtered].slice(0, MAX_RECENT_SEARCHES); 42 localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); 43}; 44 45const removeRecentSearch = (path: string) => { 46 const searches = getRecentSearches(); 47 const updated = searches.filter((s) => s.path !== path); 48 localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); 49}; 50 51export const [showSearch, setShowSearch] = createSignal(false); 52 53const SEARCH_PREFIXES: { prefix: string; description: string }[] = [ 54 { prefix: "@", description: "example.com" }, 55 { prefix: "did:", description: "web:example.com" }, 56 { prefix: "at:", description: "//example.com/com.example.test/self" }, 57 { prefix: "lex:", description: "com.example.test" }, 58 { prefix: "pds:", description: "host.example.com" }, 59]; 60 61const parsePrefix = (input: string): { prefix: string | null; query: string } => { 62 const matchedPrefix = SEARCH_PREFIXES.find((p) => input.toLowerCase().startsWith(p.prefix)); 63 if (matchedPrefix) { 64 return { 65 prefix: matchedPrefix.prefix, 66 query: input.slice(matchedPrefix.prefix.length), 67 }; 68 } 69 return { prefix: null, query: input }; 70}; 71 72export const SearchButton = () => { 73 onMount(() => window.addEventListener("keydown", keyEvent)); 74 onCleanup(() => window.removeEventListener("keydown", keyEvent)); 75 76 const keyEvent = (ev: KeyboardEvent) => { 77 if (document.querySelector("[data-modal]")) return; 78 79 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") { 80 ev.preventDefault(); 81 82 if (showSearch()) { 83 const searchInput = document.querySelector("#input") as HTMLInputElement; 84 if (searchInput && document.activeElement !== searchInput) { 85 searchInput.focus(); 86 } else { 87 setShowSearch(false); 88 } 89 } else { 90 setShowSearch(true); 91 } 92 } else if (ev.key == "Escape") { 93 ev.preventDefault(); 94 setShowSearch(false); 95 } 96 }; 97 98 return ( 99 <Button onClick={() => setShowSearch(!showSearch())}> 100 <span class="iconify lucide--search"></span> 101 <span>Search</span> 102 <Show when={canHover}> 103 <kbd class="font-sans text-neutral-400 dark:text-neutral-500"> 104 {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K 105 </kbd> 106 </Show> 107 </Button> 108 ); 109}; 110 111export const Search = () => { 112 const navigate = useNavigate(); 113 let searchInput!: HTMLInputElement; 114 const rpc = new Client({ 115 handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 116 }); 117 const [recentSearches, setRecentSearches] = createSignal<RecentSearch[]>(getRecentSearches()); 118 119 onMount(() => { 120 const handlePaste = (e: ClipboardEvent) => { 121 if (e.target === searchInput) return; 122 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 123 if (document.querySelector("[data-modal]")) return; 124 125 const pastedText = e.clipboardData?.getData("text"); 126 if (pastedText) processInput(pastedText); 127 }; 128 129 window.addEventListener("paste", handlePaste); 130 onCleanup(() => window.removeEventListener("paste", handlePaste)); 131 132 const requestUrl = new URL(location.href); 133 const requestQuery = requestUrl.searchParams.get("q"); 134 if (requestQuery !== null) { 135 requestUrl.searchParams.delete("q"); 136 history.replaceState(null, "", requestUrl.toString()); 137 processInput(requestQuery); 138 } 139 }); 140 141 createEffect(() => { 142 if (showSearch()) { 143 searchInput.focus(); 144 } else { 145 setInput(undefined); 146 setSelectedIndex(-1); 147 setSearch(undefined); 148 } 149 }); 150 151 const fetchTypeahead = async (input: string | undefined) => { 152 if (!input) return []; 153 154 const { prefix, query } = parsePrefix(input); 155 156 if (prefix === "@") { 157 if (!query.length) return []; 158 159 const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { 160 params: { q: query, limit: 5 }, 161 }); 162 if (res.ok) { 163 return res.data.actors; 164 } 165 } 166 167 return []; 168 }; 169 170 const [input, setInput] = createSignal<string>(); 171 const [selectedIndex, setSelectedIndex] = createSignal(-1); 172 const [search, { mutate: setSearch }] = createResource( 173 createDebouncedValue(input, 200), 174 fetchTypeahead, 175 ); 176 177 const getRecentSuggestions = () => { 178 const currentInput = input()?.toLowerCase(); 179 if (!currentInput) return recentSearches(); 180 return recentSearches().filter((r) => r.label.toLowerCase().includes(currentInput)); 181 }; 182 183 const saveRecentSearch = (path: string, label: string, type: RecentSearch["type"]) => { 184 addRecentSearch({ path, label, type }); 185 setRecentSearches(getRecentSearches()); 186 }; 187 188 const processInput = async (input: string) => { 189 input = input.trim().replace(/^@/, ""); 190 if (!input.length) return; 191 192 if (input.includes("%")) { 193 try { 194 input = decodeURIComponent(input); 195 } catch {} 196 } 197 198 setShowSearch(false); 199 200 const { prefix, query } = parsePrefix(input); 201 202 if (prefix === "@") { 203 const path = `/at://${query}`; 204 saveRecentSearch(path, query, "handle"); 205 navigate(path); 206 } else if (prefix === "did:") { 207 const path = `/at://did:${query}`; 208 saveRecentSearch(path, `did:${query}`, "did"); 209 navigate(path); 210 } else if (prefix === "at:") { 211 const path = `/${input}`; 212 saveRecentSearch(path, input, "at-uri"); 213 navigate(path); 214 } else if (prefix === "lex:") { 215 if (query.split(".").length >= 3) { 216 const nsid = query as Nsid; 217 const res = await resolveLexiconAuthority(nsid); 218 const path = `/at://${res}/com.atproto.lexicon.schema/${nsid}`; 219 saveRecentSearch(path, query, "lexicon"); 220 navigate(path); 221 } else { 222 const did = await resolveLexiconAuthorityDirect(query); 223 const path = `/at://${did}/com.atproto.lexicon.schema`; 224 saveRecentSearch(path, query, "lexicon"); 225 navigate(path); 226 } 227 } else if (prefix === "pds:") { 228 const path = `/${query}`; 229 saveRecentSearch(path, query, "pds"); 230 navigate(path); 231 } else if (input.startsWith("https://") || input.startsWith("http://")) { 232 const hostLength = input.indexOf("/", 8); 233 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); 234 235 if (!(host in appList)) { 236 const path = `/${input.replace("https://", "").replace("http://", "").replace("/", "")}`; 237 saveRecentSearch(path, input, "url"); 238 navigate(path); 239 } else { 240 const app = appList[host as AppUrl]; 241 const pathParts = input.slice(hostLength + 1).split("/"); 242 const uri = appHandleLink[app](pathParts); 243 const path = `/${uri}`; 244 saveRecentSearch(path, input, "url"); 245 navigate(path); 246 } 247 } else { 248 const path = `/at://${input.replace("at://", "")}`; 249 const type = input.split("/").length > 1 ? "at-uri" : "handle"; 250 saveRecentSearch(path, input, type); 251 navigate(path); 252 } 253 }; 254 255 return ( 256 <Modal 257 open={showSearch()} 258 onClose={() => setShowSearch(false)} 259 alignTop 260 contentClass="dark:bg-dark-200 dark:shadow-dark-700 pointer-events-auto mx-3 w-full max-w-lg rounded-lg border-[0.5px] min-w-0 border-neutral-300 bg-white shadow-md dark:border-neutral-700" 261 > 262 <form 263 class="w-full" 264 onsubmit={(e) => { 265 e.preventDefault(); 266 processInput(searchInput.value); 267 }} 268 > 269 <label for="input" class="hidden"> 270 Search or paste a link 271 </label> 272 <div 273 class={`flex items-center gap-2 px-3 ${ 274 getRecentSuggestions().length > 0 || search()?.length ? "rounded-t-lg" : "rounded-lg" 275 }`} 276 > 277 <label 278 for="input" 279 class="iconify lucide--search text-neutral-500 dark:text-neutral-400" 280 ></label> 281 <input 282 type="text" 283 spellcheck={false} 284 autocapitalize="off" 285 autocomplete="off" 286 placeholder="Search or paste a link..." 287 ref={searchInput} 288 id="input" 289 class="grow py-2.5 select-none placeholder:text-sm focus:outline-none" 290 value={input() ?? ""} 291 onInput={(e) => { 292 setInput(e.currentTarget.value); 293 setSelectedIndex(-1); 294 }} 295 onBlur={() => setSelectedIndex(-1)} 296 onKeyDown={(e) => { 297 const results = search(); 298 const recent = getRecentSuggestions(); 299 const totalSuggestions = recent.length + (results?.length || 0); 300 301 if (!totalSuggestions) return; 302 303 if (e.key === "ArrowDown") { 304 e.preventDefault(); 305 setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % totalSuggestions)); 306 } else if (e.key === "ArrowUp") { 307 e.preventDefault(); 308 setSelectedIndex((prev) => 309 prev === -1 ? 310 totalSuggestions - 1 311 : (prev - 1 + totalSuggestions) % totalSuggestions, 312 ); 313 } else if (e.key === "Enter") { 314 const index = selectedIndex(); 315 if (index >= 0) { 316 e.preventDefault(); 317 if (index < recent.length) { 318 const item = recent[index]; 319 addRecentSearch(item); 320 setRecentSearches(getRecentSearches()); 321 setShowSearch(false); 322 navigate(item.path); 323 } else { 324 const adjustedIndex = index - recent.length; 325 if (results && results[adjustedIndex]) { 326 const actor = results[adjustedIndex]; 327 const path = `/at://${actor.did}`; 328 saveRecentSearch(path, actor.handle, "handle"); 329 setShowSearch(false); 330 navigate(path); 331 } 332 } 333 } else if (results?.length && recent.length === 0) { 334 e.preventDefault(); 335 const actor = results[0]; 336 const path = `/at://${actor.did}`; 337 saveRecentSearch(path, actor.handle, "handle"); 338 setShowSearch(false); 339 navigate(path); 340 } 341 } 342 }} 343 /> 344 </div> 345 346 <Show when={getRecentSuggestions().length > 0 || search()?.length}> 347 <div 348 class={`flex w-full flex-col overflow-hidden border-t border-neutral-200 dark:border-neutral-700 ${input() ? "rounded-b-md" : ""}`} 349 onMouseDown={(e) => e.preventDefault()} 350 > 351 {/* Recent searches */} 352 <Show when={getRecentSuggestions().length > 0}> 353 <div class="mt-2 mb-1 flex items-center justify-between px-3"> 354 <span class="text-xs font-medium text-neutral-500 dark:text-neutral-400"> 355 Recent 356 </span> 357 <button 358 type="button" 359 class="text-xs not-hover:text-neutral-500 dark:not-hover:text-neutral-400" 360 onClick={() => { 361 localStorage.removeItem(RECENT_SEARCHES_KEY); 362 setRecentSearches([]); 363 }} 364 > 365 Clear all 366 </button> 367 </div> 368 <For each={getRecentSuggestions()}> 369 {(recent, index) => { 370 const icon = 371 recent.type === "handle" ? "lucide--at-sign" 372 : recent.type === "did" ? "lucide--user-round" 373 : recent.type === "at-uri" ? "lucide--link" 374 : recent.type === "lexicon" ? "lucide--book-open" 375 : recent.type === "pds" ? "lucide--hard-drive" 376 : "lucide--globe"; 377 return ( 378 <div 379 class={`group flex items-center ${ 380 index() === selectedIndex() ? 381 "bg-neutral-200 dark:bg-neutral-700" 382 : "dark:hover:bg-dark-100 hover:bg-neutral-100" 383 }`} 384 > 385 <A 386 href={recent.path} 387 class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-sm" 388 onClick={() => { 389 addRecentSearch(recent); 390 setRecentSearches(getRecentSearches()); 391 setShowSearch(false); 392 }} 393 > 394 <span 395 class={`iconify ${icon} shrink-0 text-neutral-500 dark:text-neutral-400`} 396 ></span> 397 <span class="truncate">{recent.label}</span> 398 </A> 399 <button 400 type="button" 401 class="flex items-center p-2.5 opacity-0 not-hover:text-neutral-500 group-hover:opacity-100 dark:not-hover:text-neutral-400" 402 onClick={() => { 403 removeRecentSearch(recent.path); 404 setRecentSearches(getRecentSearches()); 405 }} 406 > 407 <span class="iconify lucide--x text-base"></span> 408 </button> 409 </div> 410 ); 411 }} 412 </For> 413 </Show> 414 415 {/* Typeahead results */} 416 <For each={search()}> 417 {(actor, index) => { 418 const adjustedIndex = getRecentSuggestions().length + index(); 419 const path = `/at://${actor.did}`; 420 return ( 421 <A 422 class={`flex items-center gap-2 px-3 py-1.5 ${ 423 adjustedIndex === selectedIndex() ? 424 "bg-neutral-200 dark:bg-neutral-700" 425 : "dark:hover:bg-dark-100 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700" 426 }`} 427 href={path} 428 onClick={() => { 429 saveRecentSearch(path, actor.handle, "handle"); 430 setShowSearch(false); 431 }} 432 > 433 <img 434 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} 435 class="size-8 rounded-full" 436 /> 437 <div class="flex min-w-0 flex-col"> 438 <Show when={actor.displayName}> 439 <span class="truncate text-sm font-medium">{actor.displayName}</span> 440 </Show> 441 <span class="truncate text-xs text-neutral-600 dark:text-neutral-400"> 442 @{actor.handle} 443 </span> 444 </div> 445 </A> 446 ); 447 }} 448 </For> 449 </div> 450 </Show> 451 <Show when={!input()}> 452 <div class="flex flex-col gap-1 border-t border-neutral-200 px-3 py-2 text-xs text-neutral-500 dark:border-neutral-700 dark:text-neutral-400"> 453 <div class="flex flex-wrap gap-1.5"> 454 <div> 455 @<span class="text-neutral-400 dark:text-neutral-500">retr0.id</span> 456 </div> 457 <div>did:</div> 458 <div>at://</div> 459 <div> 460 lex: 461 <span class="text-neutral-400 dark:text-neutral-500">app.bsky.feed.post</span> 462 </div> 463 <div> 464 pds: 465 <span class="text-neutral-400 dark:text-neutral-500">tngl.sh</span> 466 </div> 467 </div> 468 <span>Bluesky, Tangled, Pinksea, or Frontpage links</span> 469 </div> 470 </Show> 471 </form> 472 </Modal> 473 ); 474};