atmosphere explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

new search suggestions

Juliet de032a14 be0f33af

+157 -35
+156 -34
src/components/search.tsx
··· 10 10 11 11 export const [showSearch, setShowSearch] = createSignal(false); 12 12 13 + const 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 + 21 + const 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 + 13 32 const SearchButton = () => { 14 33 onMount(() => window.addEventListener("keydown", keyEvent)); 15 34 onCleanup(() => window.removeEventListener("keydown", keyEvent)); ··· 49 68 }); 50 69 51 70 onMount(() => { 52 - if (!isTouchDevice || useLocation().pathname !== "/") searchInput.focus(); 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)); 53 83 }); 54 84 55 85 const fetchTypeahead = async (input: string) => { 56 - if (!input.length) return []; 57 - const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { 58 - params: { q: input, limit: 5 }, 59 - }); 60 - if (res.ok) { 61 - return res.data.actors; 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 + } 62 97 } 98 + 63 99 return []; 64 100 }; 65 101 66 102 const [input, setInput] = createSignal<string>(); 67 103 const [selectedIndex, setSelectedIndex] = createSignal(-1); 68 - const [search] = createResource(createDebouncedValue(input, 250), fetchTypeahead); 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 + }; 69 116 70 117 const processInput = async (input: string) => { 71 118 input = input.trim().replace(/^@/, ""); 72 119 if (!input.length) return; 73 - const index = selectedIndex() >= 0 ? selectedIndex() : 0; 120 + 74 121 setShowSearch(false); 75 122 setInput(undefined); 76 - if (search()?.length && selectedIndex() !== -1) { 77 - navigate(`/at://${search()![index].did}`); 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}`); 78 139 } else if (input.startsWith("https://") || input.startsWith("http://")) { 79 140 const hostLength = input.indexOf("/", 8); 80 141 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); ··· 88 149 const uri = appHandleLink[app](path); 89 150 navigate(`/${uri}`); 90 151 } 91 - } else if (input.startsWith("lex:")) { 92 - const nsid = input.replace("lex:", "") as Nsid; 93 - const res = await resolveLexiconAuthority(nsid); 94 - navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`); 95 152 } else { 96 153 navigate(`/at://${input.replace("at://", "")}`); 97 154 } 98 - setSelectedIndex(-1); 99 155 }; 100 156 101 157 return ( ··· 117 173 <input 118 174 type="text" 119 175 spellcheck={false} 120 - placeholder="PDS, AT URI, NSID, DID, or handle" 176 + placeholder="Handle, DID, AT URI, NSID, PDS" 121 177 ref={searchInput} 122 178 id="input" 123 179 class="grow py-1 select-none placeholder:text-sm focus:outline-none" ··· 125 181 onInput={(e) => { 126 182 setInput(e.currentTarget.value); 127 183 setSelectedIndex(-1); 184 + }} 185 + onFocus={() => setIsFocused(true)} 186 + onBlur={() => { 187 + setSelectedIndex(-1); 188 + setIsFocused(false); 128 189 }} 129 190 onKeyDown={(e) => { 130 191 const results = search(); 131 - if (!results?.length) return; 192 + const prefixSuggestions = getPrefixSuggestions(); 193 + const totalSuggestions = (prefixSuggestions.length || 0) + (results?.length || 0); 194 + 195 + if (!totalSuggestions) return; 132 196 133 197 if (e.key === "ArrowDown") { 134 198 e.preventDefault(); 135 - setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % results.length)); 199 + setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % totalSuggestions)); 136 200 } else if (e.key === "ArrowUp") { 137 201 e.preventDefault(); 138 202 setSelectedIndex((prev) => 139 - prev === -1 ? results.length - 1 : (prev - 1 + results.length) % results.length, 203 + prev === -1 ? 204 + totalSuggestions - 1 205 + : (prev - 1 + totalSuggestions) % totalSuggestions, 140 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 + } 141 232 } 142 233 }} 143 234 /> ··· 151 242 </button> 152 243 </Show> 153 244 </div> 154 - <Show when={search()?.length && input()}> 155 - <div 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"> 156 - <For each={search()}> 157 - {(actor, index) => ( 158 - <A 159 - class={`flex items-center gap-2 rounded-lg p-1 transition-colors duration-150 ${ 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 transition-colors duration-150 ${ 160 256 index() === selectedIndex() ? 161 257 "bg-neutral-200 dark:bg-neutral-700" 162 258 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 163 259 }`} 164 - href={`/at://${actor.did}`} 165 - onClick={() => setShowSearch(false)} 260 + onClick={() => { 261 + setInput(prefixItem.prefix); 262 + setSelectedIndex(-1); 263 + searchInput.focus(); 264 + }} 166 265 > 167 - <img 168 - src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} 169 - class="size-8 rounded-full" 170 - /> 171 - <span>{actor.handle}</span> 172 - </A> 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> 173 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 transition-colors duration-150 ${ 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 + }} 174 296 </For> 175 297 </div> 176 298 </Show>
+1 -1
src/views/home.tsx
··· 1 1 export const Home = () => { 2 2 return ( 3 - <div class="flex w-full flex-col gap-4 wrap-break-word"> 3 + <div class="flex w-full flex-col gap-3 wrap-break-word"> 4 4 <div class="flex flex-col gap-0.5"> 5 5 <div> 6 6 <span class="text-xl font-semibold">AT Protocol Explorer</span>