forked from
pds.ls/pdsls
atmosphere explorer
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};