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