atmosphere explorer

recent search

handle.invalid f25c0a53 c7b58b53

verified
+196 -140
+193 -131
src/components/search.tsx
··· 16 16 import { createDebouncedValue } from "../utils/hooks/debounced"; 17 17 import { Modal } from "./modal"; 18 18 19 + type RecentSearch = { 20 + path: string; 21 + label: string; 22 + type: "handle" | "did" | "at-uri" | "lexicon" | "pds" | "url"; 23 + }; 24 + 25 + const RECENT_SEARCHES_KEY = "recent-searches"; 26 + const MAX_RECENT_SEARCHES = 5; 27 + 28 + const getRecentSearches = (): RecentSearch[] => { 29 + try { 30 + const stored = localStorage.getItem(RECENT_SEARCHES_KEY); 31 + return stored ? JSON.parse(stored) : []; 32 + } catch { 33 + return []; 34 + } 35 + }; 36 + 37 + const addRecentSearch = (search: RecentSearch) => { 38 + const searches = getRecentSearches(); 39 + const filtered = searches.filter((s) => s.path !== search.path); 40 + const updated = [search, ...filtered].slice(0, MAX_RECENT_SEARCHES); 41 + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); 42 + }; 43 + 44 + const removeRecentSearch = (path: string) => { 45 + const searches = getRecentSearches(); 46 + const updated = searches.filter((s) => s.path !== path); 47 + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); 48 + }; 49 + 19 50 export const [showSearch, setShowSearch] = createSignal(false); 20 51 21 52 const SEARCH_PREFIXES: { prefix: string; description: string }[] = [ ··· 37 68 return { prefix: null, query: input }; 38 69 }; 39 70 40 - const SearchButton = () => { 71 + export const SearchButton = () => { 41 72 onMount(() => window.addEventListener("keydown", keyEvent)); 42 73 onCleanup(() => window.removeEventListener("keydown", keyEvent)); 43 74 ··· 79 110 ); 80 111 }; 81 112 82 - const Search = () => { 113 + export const Search = () => { 83 114 const navigate = useNavigate(); 84 115 let searchInput!: HTMLInputElement; 85 116 const rpc = new Client({ 86 117 handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 87 118 }); 119 + const [recentSearches, setRecentSearches] = createSignal<RecentSearch[]>(getRecentSearches()); 88 120 89 121 onMount(() => { 90 122 const handlePaste = (e: ClipboardEvent) => { ··· 136 168 fetchTypeahead, 137 169 ); 138 170 139 - const getPrefixSuggestions = () => { 140 - const currentInput = input(); 141 - if (!currentInput) return SEARCH_PREFIXES; 171 + const getRecentSuggestions = () => { 172 + const currentInput = input()?.toLowerCase(); 173 + if (!currentInput) return recentSearches(); 174 + return recentSearches().filter((r) => r.label.toLowerCase().includes(currentInput)); 175 + }; 142 176 143 - const { prefix, query } = parsePrefix(currentInput); 144 - if (prefix && query.length > 0) return []; 145 - 146 - return SEARCH_PREFIXES.filter((p) => p.prefix.startsWith(currentInput.toLowerCase())); 177 + const saveRecentSearch = (path: string, label: string, type: RecentSearch["type"]) => { 178 + addRecentSearch({ path, label, type }); 179 + setRecentSearches(getRecentSearches()); 147 180 }; 148 181 149 182 const processInput = async (input: string) => { ··· 161 194 const { prefix, query } = parsePrefix(input); 162 195 163 196 if (prefix === "@") { 164 - navigate(`/at://${query}`); 197 + const path = `/at://${query}`; 198 + saveRecentSearch(path, query, "handle"); 199 + navigate(path); 165 200 } else if (prefix === "did:") { 166 - navigate(`/at://did:${query}`); 201 + const path = `/at://did:${query}`; 202 + saveRecentSearch(path, `did:${query}`, "did"); 203 + navigate(path); 167 204 } else if (prefix === "at:") { 168 - navigate(`/${input}`); 205 + const path = `/${input}`; 206 + saveRecentSearch(path, input, "at-uri"); 207 + navigate(path); 169 208 } else if (prefix === "lex:") { 170 209 if (query.split(".").length >= 3) { 171 210 const nsid = query as Nsid; 172 211 const res = await resolveLexiconAuthority(nsid); 173 - navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); 212 + const path = `/at://${res}/com.atproto.lexicon.schema/${nsid}`; 213 + saveRecentSearch(path, query, "lexicon"); 214 + navigate(path); 174 215 } else { 175 216 const did = await resolveLexiconAuthorityDirect(query); 176 - navigate(`/at://${did}/com.atproto.lexicon.schema`); 217 + const path = `/at://${did}/com.atproto.lexicon.schema`; 218 + saveRecentSearch(path, query, "lexicon"); 219 + navigate(path); 177 220 } 178 221 } else if (prefix === "pds:") { 179 - navigate(`/${query}`); 222 + const path = `/${query}`; 223 + saveRecentSearch(path, query, "pds"); 224 + navigate(path); 180 225 } else if (input.startsWith("https://") || input.startsWith("http://")) { 181 226 const hostLength = input.indexOf("/", 8); 182 227 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); 183 228 184 229 if (!(host in appList)) { 185 - navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`); 230 + const path = `/${input.replace("https://", "").replace("http://", "").replace("/", "")}`; 231 + saveRecentSearch(path, input, "url"); 232 + navigate(path); 186 233 } else { 187 234 const app = appList[host as AppUrl]; 188 - const path = input.slice(hostLength + 1).split("/"); 189 - 190 - const uri = appHandleLink[app](path); 191 - navigate(`/${uri}`); 235 + const pathParts = input.slice(hostLength + 1).split("/"); 236 + const uri = appHandleLink[app](pathParts); 237 + const path = `/${uri}`; 238 + saveRecentSearch(path, input, "url"); 239 + navigate(path); 192 240 } 193 241 } else { 194 - navigate(`/at://${input.replace("at://", "")}`); 242 + const path = `/at://${input.replace("at://", "")}`; 243 + const type = input.split("/").length > 1 ? "at-uri" : "handle"; 244 + saveRecentSearch(path, input, type); 245 + navigate(path); 195 246 } 196 247 }; 197 248 ··· 200 251 open={showSearch()} 201 252 onClose={() => setShowSearch(false)} 202 253 alignTop 203 - contentClass="dark:bg-dark-200 dark:shadow-dark-700 pointer-events-auto mx-3 w-full max-w-lg rounded-lg border-[0.5px] border-neutral-300 bg-white shadow-md dark:border-neutral-700" 254 + 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" 204 255 > 205 256 <form 206 257 class="w-full" ··· 210 261 }} 211 262 > 212 263 <label for="input" class="hidden"> 213 - PDS URL, AT URI, NSID, DID, or handle 264 + Search or paste a link 214 265 </label> 215 266 <div 216 - class={`flex items-center gap-2 px-2 ${ 217 - getPrefixSuggestions().length > 0 || search()?.length ? "rounded-t-lg" : "rounded-lg" 267 + class={`flex items-center gap-2 px-3 ${ 268 + getRecentSuggestions().length > 0 || search()?.length ? "rounded-t-lg" : "rounded-lg" 218 269 }`} 219 270 > 220 271 <label ··· 225 276 type="text" 226 277 spellcheck={false} 227 278 autocapitalize="off" 228 - placeholder="Handle, DID, AT URI, NSID, PDS" 279 + placeholder="Search or paste a link..." 229 280 ref={searchInput} 230 281 id="input" 231 282 class="grow py-2.5 select-none placeholder:text-sm focus:outline-none" ··· 237 288 onBlur={() => setSelectedIndex(-1)} 238 289 onKeyDown={(e) => { 239 290 const results = search(); 240 - const prefixSuggestions = getPrefixSuggestions(); 241 - const totalSuggestions = (prefixSuggestions.length || 0) + (results?.length || 0); 291 + const recent = getRecentSuggestions(); 292 + const totalSuggestions = recent.length + (results?.length || 0); 242 293 243 294 if (!totalSuggestions) return; 244 295 ··· 256 307 const index = selectedIndex(); 257 308 if (index >= 0) { 258 309 e.preventDefault(); 259 - if (index < prefixSuggestions.length) { 260 - const selectedPrefix = prefixSuggestions[index]; 261 - setInput(selectedPrefix.prefix); 262 - setSelectedIndex(-1); 263 - searchInput.focus(); 310 + if (index < recent.length) { 311 + const item = recent[index]; 312 + addRecentSearch(item); 313 + setRecentSearches(getRecentSearches()); 314 + setShowSearch(false); 315 + navigate(item.path); 264 316 } else { 265 - const adjustedIndex = index - prefixSuggestions.length; 317 + const adjustedIndex = index - recent.length; 266 318 if (results && results[adjustedIndex]) { 319 + const actor = results[adjustedIndex]; 320 + const path = `/at://${actor.did}`; 321 + saveRecentSearch(path, actor.handle, "handle"); 267 322 setShowSearch(false); 268 - navigate(`/at://${results[adjustedIndex].did}`); 323 + navigate(path); 269 324 } 270 325 } 271 - } else if (results?.length && prefixSuggestions.length === 0) { 326 + } else if (results?.length && recent.length === 0) { 272 327 e.preventDefault(); 328 + const actor = results[0]; 329 + const path = `/at://${actor.did}`; 330 + saveRecentSearch(path, actor.handle, "handle"); 273 331 setShowSearch(false); 274 - navigate(`/at://${results[0].did}`); 332 + navigate(path); 275 333 } 276 334 } 277 335 }} 278 336 /> 279 - <Show when={input()} fallback={ListUrlsTooltip()}> 280 - <button 281 - type="button" 282 - class="dark:hover:bg-dark-100 flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700" 283 - onClick={() => setInput(undefined)} 284 - > 285 - <span class="iconify lucide--x"></span> 286 - </button> 287 - </Show> 288 337 </div> 289 338 290 - <Show when={getPrefixSuggestions().length > 0 || (input() && search()?.length)}> 339 + <Show when={getRecentSuggestions().length > 0 || search()?.length}> 291 340 <div 292 - class="flex w-full flex-col border-t border-neutral-200 p-2 dark:border-neutral-700" 341 + class={`flex w-full flex-col overflow-hidden border-t border-neutral-200 dark:border-neutral-700 ${input() ? "rounded-b-md" : ""}`} 293 342 onMouseDown={(e) => e.preventDefault()} 294 343 > 295 - {/* Prefix suggestions */} 296 - <For each={getPrefixSuggestions()}> 297 - {(prefixItem, index) => ( 344 + {/* Recent searches */} 345 + <Show when={getRecentSuggestions().length > 0}> 346 + <div class="mt-2 mb-1 flex items-center justify-between px-3"> 347 + <span class="text-xs font-medium text-neutral-500 dark:text-neutral-400"> 348 + Recent 349 + </span> 298 350 <button 299 351 type="button" 300 - class={`flex items-center rounded-md p-2 ${ 301 - index() === selectedIndex() ? 302 - "bg-neutral-200 dark:bg-neutral-700" 303 - : "dark:hover:bg-dark-100 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700" 304 - }`} 352 + class="text-xs text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" 305 353 onClick={() => { 306 - setInput(prefixItem.prefix); 307 - setSelectedIndex(-1); 308 - searchInput.focus(); 354 + localStorage.removeItem(RECENT_SEARCHES_KEY); 355 + setRecentSearches([]); 309 356 }} 310 357 > 311 - <span class={`text-sm font-semibold`}>{prefixItem.prefix}</span> 312 - <span class="text-sm text-neutral-600 dark:text-neutral-400"> 313 - {prefixItem.description} 314 - </span> 358 + Clear all 315 359 </button> 316 - )} 317 - </For> 360 + </div> 361 + <For each={getRecentSuggestions()}> 362 + {(recent, index) => { 363 + const icon = 364 + recent.type === "handle" ? "lucide--at-sign" 365 + : recent.type === "did" ? "lucide--user-round" 366 + : recent.type === "at-uri" ? "lucide--link" 367 + : recent.type === "lexicon" ? "lucide--book-open" 368 + : recent.type === "pds" ? "lucide--hard-drive" 369 + : "lucide--globe"; 370 + return ( 371 + <div 372 + class={`group flex items-center ${ 373 + index() === selectedIndex() ? 374 + "bg-neutral-200 dark:bg-neutral-700" 375 + : "dark:hover:bg-dark-100 hover:bg-neutral-100" 376 + }`} 377 + > 378 + <A 379 + href={recent.path} 380 + class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-sm" 381 + onClick={() => { 382 + addRecentSearch(recent); 383 + setRecentSearches(getRecentSearches()); 384 + setShowSearch(false); 385 + }} 386 + > 387 + <span 388 + class={`iconify ${icon} shrink-0 text-neutral-500 dark:text-neutral-400`} 389 + ></span> 390 + <span class="truncate">{recent.label}</span> 391 + </A> 392 + <button 393 + type="button" 394 + class="mr-1 flex items-center rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-neutral-300 dark:hover:bg-neutral-600" 395 + onClick={() => { 396 + removeRecentSearch(recent.path); 397 + setRecentSearches(getRecentSearches()); 398 + }} 399 + > 400 + <span class="iconify lucide--x text-sm text-neutral-500 dark:text-neutral-400"></span> 401 + </button> 402 + </div> 403 + ); 404 + }} 405 + </For> 406 + </Show> 318 407 319 408 {/* Typeahead results */} 320 409 <For each={search()}> 321 410 {(actor, index) => { 322 - const adjustedIndex = getPrefixSuggestions().length + index(); 411 + const adjustedIndex = getRecentSuggestions().length + index(); 412 + const path = `/at://${actor.did}`; 323 413 return ( 324 414 <A 325 - class={`flex items-center gap-2 rounded-md p-2 ${ 415 + class={`flex items-center gap-2 px-3 py-1.5 ${ 326 416 adjustedIndex === selectedIndex() ? 327 417 "bg-neutral-200 dark:bg-neutral-700" 328 418 : "dark:hover:bg-dark-100 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700" 329 419 }`} 330 - href={`/at://${actor.did}`} 331 - onClick={() => setShowSearch(false)} 420 + href={path} 421 + onClick={() => { 422 + saveRecentSearch(path, actor.handle, "handle"); 423 + setShowSearch(false); 424 + }} 332 425 > 333 426 <img 334 427 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} ··· 348 441 </For> 349 442 </div> 350 443 </Show> 444 + <Show when={!input()}> 445 + <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"> 446 + <div class="flex flex-wrap gap-1.5"> 447 + <div> 448 + @<span class="text-neutral-400 dark:text-neutral-500">pdsls.dev</span> 449 + </div> 450 + <div>did:</div> 451 + <div>at://</div> 452 + <div> 453 + lex: 454 + <span class="text-neutral-400 dark:text-neutral-500">app.bsky.feed.post</span> 455 + </div> 456 + <div> 457 + pds: 458 + <span class="text-neutral-400 dark:text-neutral-500">tngl.sh</span> 459 + </div> 460 + </div> 461 + <span> 462 + Paste links from{" "} 463 + <For each={Object.values(appName).slice(0, 4)}> 464 + {(name, i) => ( 465 + <> 466 + {name} 467 + {i() < 3 ? ", " : ""} 468 + </> 469 + )} 470 + </For> 471 + {Object.keys(appName).length > 4 && <span> &amp; more</span>} 472 + </span> 473 + </div> 474 + </Show> 351 475 </form> 352 476 </Modal> 353 477 ); 354 478 }; 355 - 356 - const ListUrlsTooltip = () => { 357 - const [openList, setOpenList] = createSignal(false); 358 - 359 - let urls: Record<string, AppUrl[]> = {}; 360 - for (const [appUrl, appView] of Object.entries(appList)) { 361 - if (!urls[appView]) urls[appView] = [appUrl as AppUrl]; 362 - else urls[appView].push(appUrl as AppUrl); 363 - } 364 - 365 - return ( 366 - <> 367 - <Modal 368 - open={openList()} 369 - onClose={() => setOpenList(false)} 370 - alignTop 371 - contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-88 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md sm:w-104 dark:border-neutral-700" 372 - > 373 - <div class="mb-2 flex items-center gap-1 font-semibold"> 374 - <span class="iconify lucide--link"></span> 375 - <span>Supported URLs</span> 376 - </div> 377 - <div class="mb-2 text-sm text-neutral-600 dark:text-neutral-400"> 378 - Links that will be parsed automatically, as long as all the data necessary is on the URL. 379 - </div> 380 - <div class="flex flex-col gap-2 text-sm"> 381 - <For each={Object.entries(appName)}> 382 - {([appView, name]) => { 383 - return ( 384 - <div> 385 - <p class="font-semibold">{name}</p> 386 - <div class="grid grid-cols-2 gap-x-4 text-neutral-600 dark:text-neutral-400"> 387 - <For each={urls[appView]}> 388 - {(url) => ( 389 - <a 390 - href={`${url.startsWith("localhost:") ? "http://" : "https://"}${url}`} 391 - target="_blank" 392 - class="hover:underline active:underline" 393 - > 394 - {url} 395 - </a> 396 - )} 397 - </For> 398 - </div> 399 - </div> 400 - ); 401 - }} 402 - </For> 403 - </div> 404 - </Modal> 405 - <button 406 - type="button" 407 - class="dark:hover:bg-dark-100 flex items-center rounded-md p-1 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700" 408 - onClick={() => setOpenList(true)} 409 - > 410 - <span class="iconify lucide--help-circle text-neutral-600 dark:text-neutral-300"></span> 411 - </button> 412 - </> 413 - ); 414 - }; 415 - 416 - export { Search, SearchButton };
+3 -9
src/utils/app-urls.ts
··· 3 3 export enum App { 4 4 Bluesky, 5 5 Tangled, 6 - Frontpage, 7 6 Pinksea, 8 - Linkat, 7 + Frontpage, 9 8 } 10 9 11 10 export const appName = { 12 11 [App.Bluesky]: "Bluesky", 13 12 [App.Tangled]: "Tangled", 14 - [App.Frontpage]: "Frontpage", 15 13 [App.Pinksea]: "Pinksea", 16 - [App.Linkat]: "Linkat", 14 + [App.Frontpage]: "Frontpage", 17 15 }; 18 16 19 17 export const appList: Record<AppUrl, App> = { ··· 22 20 "bsky.app": App.Bluesky, 23 21 "catsky.social": App.Bluesky, 24 22 "deer.aylac.top": App.Bluesky, 25 - "deer-social-ayla.pages.dev": App.Bluesky, 26 23 "deer.social": App.Bluesky, 27 24 "main.bsky.dev": App.Bluesky, 28 - "social.daniela.lol": App.Bluesky, 29 25 "witchsky.app": App.Bluesky, 30 26 "tangled.org": App.Tangled, 31 27 "frontpage.fyi": App.Frontpage, 32 28 "pinksea.art": App.Pinksea, 33 - "linkat.blue": App.Linkat, 34 29 }; 35 30 36 31 export const appHandleLink: Record<App, (url: string[]) => string> = { ··· 53 48 return `at://${user}/app.bsky.graph.follow/${rkey}`; 54 49 } 55 50 } else { 56 - return `at://${user}`; 51 + return `at://${user}/app.bsky.actor.profile/self`; 57 52 } 58 53 } else if (baseType === "starter-pack") { 59 54 return `at://${user}/app.bsky.graph.starterpack/${path[2]}`; ··· 106 101 107 102 return `at://${path[0]}`; 108 103 }, 109 - [App.Linkat]: (path) => `at://${path[0]}/blue.linkat.board/self`, 110 104 };