forked from pdsls.dev/pdsls
atproto explorer
at main 5.8 kB view raw
1import { Client, CredentialManager } from "@atcute/client"; 2import { A, useLocation, useNavigate } from "@solidjs/router"; 3import { createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 4import { isTouchDevice } from "../layout"; 5import { appHandleLink, appList, AppUrl } from "../utils/app-urls"; 6import { createDebouncedValue } from "../utils/hooks/debounced"; 7 8export const [showSearch, setShowSearch] = createSignal(false); 9 10const SearchButton = () => { 11 onMount(() => window.addEventListener("keydown", keyEvent)); 12 onCleanup(() => window.removeEventListener("keydown", keyEvent)); 13 14 const keyEvent = (ev: KeyboardEvent) => { 15 if (document.querySelector("dialog")) return; 16 17 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") { 18 ev.preventDefault(); 19 setShowSearch(!showSearch()); 20 } else if (ev.key == "Escape") { 21 ev.preventDefault(); 22 setShowSearch(false); 23 } 24 }; 25 26 return ( 27 <button 28 onclick={() => setShowSearch(!showSearch())} 29 class={`flex items-center gap-0.5 rounded-lg ${isTouchDevice ? "p-1 text-xl hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" : "dark:bg-dark-100/70 box-border h-7 border-[0.5px] border-neutral-300 bg-neutral-100/70 p-1.5 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"}`} 30 > 31 <span class="iconify lucide--search"></span> 32 <Show when={!isTouchDevice}> 33 <kbd class="font-sans text-neutral-500 select-none dark:text-neutral-400"> 34 {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K 35 </kbd> 36 </Show> 37 </button> 38 ); 39}; 40 41const Search = () => { 42 const navigate = useNavigate(); 43 let searchInput!: HTMLInputElement; 44 const rpc = new Client({ 45 handler: new CredentialManager({ service: "https://public.api.bsky.app" }), 46 }); 47 48 onMount(() => { 49 if (useLocation().pathname !== "/") searchInput.focus(); 50 }); 51 52 const fetchTypeahead = async (input: string) => { 53 if (!input.length) return []; 54 const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { 55 params: { q: input, limit: 5 }, 56 }); 57 if (res.ok) { 58 return res.data.actors; 59 } 60 return []; 61 }; 62 63 const [input, setInput] = createSignal<string>(); 64 const [search] = createResource(createDebouncedValue(input, 250), fetchTypeahead); 65 66 const processInput = (input: string) => { 67 input = input.trim().replace(/^@/, ""); 68 if (!input.length) return; 69 setShowSearch(false); 70 if (input === "me" && localStorage.getItem("lastSignedIn") !== null) { 71 navigate(`/at://${localStorage.getItem("lastSignedIn")}`); 72 } else if (search()?.length) { 73 navigate(`/at://${search()![0].did}`); 74 } else if (input.startsWith("https://") || input.startsWith("http://")) { 75 const hostLength = input.indexOf("/", 8); 76 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); 77 78 if (!(host in appList)) { 79 navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`); 80 } else { 81 const app = appList[host as AppUrl]; 82 const path = input.slice(hostLength + 1).split("/"); 83 84 const uri = appHandleLink[app](path); 85 navigate(`/${uri}`); 86 } 87 } else { 88 navigate(`/at://${input.replace("at://", "")}`); 89 } 90 setShowSearch(false); 91 }; 92 93 return ( 94 <form 95 class="relative w-full" 96 onsubmit={(e) => { 97 e.preventDefault(); 98 processInput(searchInput.value); 99 }} 100 > 101 <label for="input" class="hidden"> 102 PDS URL, AT URI, or handle 103 </label> 104 <div class="dark:bg-dark-100 dark:shadow-dark-800 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-900 dark:border-neutral-700 dark:focus-within:outline-neutral-200"> 105 <span 106 class="iconify lucide--search text-neutral-500 dark:text-neutral-400" 107 onClick={() => searchInput.focus()} 108 ></span> 109 <input 110 type="text" 111 spellcheck={false} 112 placeholder="PDS URL, AT URI, DID, or handle" 113 ref={searchInput} 114 id="input" 115 class="grow select-none placeholder:text-sm focus:outline-none" 116 value={input() ?? ""} 117 onInput={(e) => setInput(e.currentTarget.value)} 118 /> 119 <Show when={input()}> 120 <button 121 type="button" 122 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" 123 onClick={() => setInput(undefined)} 124 > 125 <span class="iconify lucide--x"></span> 126 </button> 127 </Show> 128 </div> 129 <Show when={search()?.length && input()}> 130 <div class="dark:bg-dark-300 dark:shadow-dark-800 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"> 131 <For each={search()}> 132 {(actor) => ( 133 <A 134 class="flex items-center gap-2 rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 135 href={`/at://${actor.did}`} 136 onClick={() => setShowSearch(false)} 137 > 138 <img 139 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} 140 class="size-8 rounded-full" 141 /> 142 <span>{actor.handle}</span> 143 </A> 144 )} 145 </For> 146 </div> 147 </Show> 148 </form> 149 ); 150}; 151 152export { Search, SearchButton };