1import { Client, CredentialManager } from "@atcute/client";
2import { Nsid } from "@atcute/lexicons";
3import { A, useLocation, useNavigate } from "@solidjs/router";
4import { createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
5import { isTouchDevice } from "../layout";
6import { resolveLexiconAuthority } from "../utils/api";
7import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls";
8import { createDebouncedValue } from "../utils/hooks/debounced";
9import { Modal } from "./modal";
10
11export const [showSearch, setShowSearch] = createSignal(false);
12
13const SearchButton = () => {
14 onMount(() => window.addEventListener("keydown", keyEvent));
15 onCleanup(() => window.removeEventListener("keydown", keyEvent));
16
17 const keyEvent = (ev: KeyboardEvent) => {
18 if (document.querySelector("[data-modal]")) return;
19
20 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") {
21 ev.preventDefault();
22 setShowSearch(!showSearch());
23 } else if (ev.key == "Escape") {
24 ev.preventDefault();
25 setShowSearch(false);
26 }
27 };
28
29 return (
30 <button
31 onclick={() => setShowSearch(!showSearch())}
32 class={`flex items-center gap-1 rounded-md ${isTouchDevice ? "p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" : "dark:bg-dark-100/70 text-baseline mr-1 box-border h-7 border-[0.5px] border-neutral-300 bg-neutral-100/70 px-2 text-xs hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"}`}
33 >
34 <span class={`iconify lucide--search ${isTouchDevice ? "text-lg" : ""}`}></span>
35 <Show when={!isTouchDevice}>
36 <kbd class="font-sans leading-none text-neutral-500 select-none dark:text-neutral-400">
37 {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K
38 </kbd>
39 </Show>
40 </button>
41 );
42};
43
44const Search = () => {
45 const navigate = useNavigate();
46 let searchInput!: HTMLInputElement;
47 const rpc = new Client({
48 handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
49 });
50
51 onMount(() => {
52 if (!isTouchDevice || useLocation().pathname !== "/") searchInput.focus();
53 });
54
55 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;
62 }
63 return [];
64 };
65
66 const [input, setInput] = createSignal<string>();
67 const [selectedIndex, setSelectedIndex] = createSignal(-1);
68 const [search] = createResource(createDebouncedValue(input, 250), fetchTypeahead);
69
70 const processInput = async (input: string) => {
71 input = input.trim().replace(/^@/, "");
72 if (!input.length) return;
73 const index = selectedIndex() >= 0 ? selectedIndex() : 0;
74 setShowSearch(false);
75 setInput(undefined);
76 if (search()?.length && selectedIndex() !== -1) {
77 navigate(`/at://${search()![index].did}`);
78 } else if (input.startsWith("https://") || input.startsWith("http://")) {
79 const hostLength = input.indexOf("/", 8);
80 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", "");
81
82 if (!(host in appList)) {
83 navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`);
84 } else {
85 const app = appList[host as AppUrl];
86 const path = input.slice(hostLength + 1).split("/");
87
88 const uri = appHandleLink[app](path);
89 navigate(`/${uri}`);
90 }
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 } else {
96 navigate(`/at://${input.replace("at://", "")}`);
97 }
98 setSelectedIndex(-1);
99 };
100
101 return (
102 <form
103 class="relative w-full"
104 onsubmit={(e) => {
105 e.preventDefault();
106 processInput(searchInput.value);
107 }}
108 >
109 <label for="input" class="hidden">
110 PDS URL, AT URI, NSID, DID, or handle
111 </label>
112 <div class="dark:bg-dark-100 dark:shadow-dark-700 flex items-center gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 shadow-xs focus-within:outline-[1px] focus-within:outline-neutral-600 dark:border-neutral-600 dark:focus-within:outline-neutral-400">
113 <label
114 for="input"
115 class="iconify lucide--search text-neutral-500 dark:text-neutral-400"
116 ></label>
117 <input
118 type="text"
119 spellcheck={false}
120 placeholder="PDS, AT URI, NSID, DID, or handle"
121 ref={searchInput}
122 id="input"
123 class="grow py-1 select-none placeholder:text-sm focus:outline-none"
124 value={input() ?? ""}
125 onInput={(e) => {
126 setInput(e.currentTarget.value);
127 setSelectedIndex(-1);
128 }}
129 onKeyDown={(e) => {
130 const results = search();
131 if (!results?.length) return;
132
133 if (e.key === "ArrowDown") {
134 e.preventDefault();
135 setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % results.length));
136 } else if (e.key === "ArrowUp") {
137 e.preventDefault();
138 setSelectedIndex((prev) =>
139 prev === -1 ? results.length - 1 : (prev - 1 + results.length) % results.length,
140 );
141 }
142 }}
143 />
144 <Show when={input()} fallback={ListUrlsTooltip()}>
145 <button
146 type="button"
147 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"
148 onClick={() => setInput(undefined)}
149 >
150 <span class="iconify lucide--x"></span>
151 </button>
152 </Show>
153 </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 ${
160 index() === selectedIndex() ?
161 "bg-neutral-200 dark:bg-neutral-700"
162 : "hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
163 }`}
164 href={`/at://${actor.did}`}
165 onClick={() => setShowSearch(false)}
166 >
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>
173 )}
174 </For>
175 </div>
176 </Show>
177 </form>
178 );
179};
180
181const ListUrlsTooltip = () => {
182 const [openList, setOpenList] = createSignal(false);
183
184 let urls: Record<string, AppUrl[]> = {};
185 for (const [appUrl, appView] of Object.entries(appList)) {
186 if (!urls[appView]) urls[appView] = [appUrl as AppUrl];
187 else urls[appView].push(appUrl as AppUrl);
188 }
189
190 return (
191 <>
192 <Modal open={openList()} onClose={() => setOpenList(false)}>
193 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-16 left-[50%] w-88 -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 sm:w-104 dark:border-neutral-700 starting:opacity-0">
194 <div class="mb-2 flex items-center gap-1 font-semibold">
195 <span class="iconify lucide--link"></span>
196 <span>Supported URLs</span>
197 </div>
198 <div class="mb-2 text-sm text-neutral-600 dark:text-neutral-400">
199 Links that will be parsed automatically, as long as all the data necessary is on the
200 URL.
201 </div>
202 <div class="flex flex-col gap-2 text-sm">
203 <For each={Object.entries(appName)}>
204 {([appView, name]) => {
205 return (
206 <div>
207 <p class="font-semibold">{name}</p>
208 <div class="grid grid-cols-2 gap-x-4 text-neutral-600 dark:text-neutral-400">
209 <For each={urls[appView]}>
210 {(url) => (
211 <a
212 href={`${url.startsWith("localhost:") ? "http://" : "https://"}${url}`}
213 target="_blank"
214 class="hover:underline active:underline"
215 >
216 {url}
217 </a>
218 )}
219 </For>
220 </div>
221 </div>
222 );
223 }}
224 </For>
225 </div>
226 </div>
227 </Modal>
228 <button
229 type="button"
230 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"
231 onClick={() => setOpenList(true)}
232 >
233 <span class="iconify lucide--help-circle"></span>
234 </button>
235 </>
236 );
237};
238
239export { Search, SearchButton };