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("dialog")) 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-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"}`}
33 >
34 <span class="iconify lucide--search"></span>
35 <Show when={!isTouchDevice}>
36 <kbd class="font-sans 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 [search] = createResource(createDebouncedValue(input, 250), fetchTypeahead);
68
69 const processInput = async (input: string) => {
70 input = input.trim().replace(/^@/, "");
71 if (!input.length) return;
72 setShowSearch(false);
73 if (search()?.length) {
74 navigate(`/at://${search()![0].did}`);
75 } else if (input.startsWith("https://") || input.startsWith("http://")) {
76 const hostLength = input.indexOf("/", 8);
77 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", "");
78
79 if (!(host in appList)) {
80 navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`);
81 } else {
82 const app = appList[host as AppUrl];
83 const path = input.slice(hostLength + 1).split("/");
84
85 const uri = appHandleLink[app](path);
86 navigate(`/${uri}`);
87 }
88 } else if (input.startsWith("lex:")) {
89 const nsid = input.replace("lex:", "") as Nsid;
90 const res = await resolveLexiconAuthority(nsid);
91 navigate(`/at://${res}/com.atproto.lexicon.schema/${nsid}`);
92 } else {
93 navigate(`/at://${input.replace("at://", "")}`);
94 }
95 };
96
97 return (
98 <form
99 class="relative w-full"
100 onsubmit={(e) => {
101 e.preventDefault();
102 processInput(searchInput.value);
103 }}
104 >
105 <label for="input" class="hidden">
106 PDS URL, AT URI, NSID, DID, or handle
107 </label>
108 <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">
109 <label
110 for="input"
111 class="iconify lucide--search text-neutral-500 dark:text-neutral-400"
112 ></label>
113 <input
114 type="text"
115 spellcheck={false}
116 placeholder="PDS, AT URI, NSID, DID, or handle"
117 ref={searchInput}
118 id="input"
119 class="grow py-1 select-none placeholder:text-sm focus:outline-none"
120 value={input() ?? ""}
121 onInput={(e) => setInput(e.currentTarget.value)}
122 />
123 <Show when={input()} fallback={ListUrlsTooltip()}>
124 <button
125 type="button"
126 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"
127 onClick={() => setInput(undefined)}
128 >
129 <span class="iconify lucide--x"></span>
130 </button>
131 </Show>
132 </div>
133 <Show when={search()?.length && input()}>
134 <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">
135 <For each={search()}>
136 {(actor) => (
137 <A
138 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"
139 href={`/at://${actor.did}`}
140 onClick={() => setShowSearch(false)}
141 >
142 <img
143 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")}
144 class="size-8 rounded-full"
145 />
146 <span>{actor.handle}</span>
147 </A>
148 )}
149 </For>
150 </div>
151 </Show>
152 </form>
153 );
154};
155
156const ListUrlsTooltip = () => {
157 const [openList, setOpenList] = createSignal(false);
158
159 let urls: Record<string, AppUrl[]> = {};
160 for (const [appUrl, appView] of Object.entries(appList)) {
161 if (!urls[appView]) urls[appView] = [appUrl as AppUrl];
162 else urls[appView].push(appUrl as AppUrl);
163 }
164
165 return (
166 <>
167 <Modal open={openList()} onClose={() => setOpenList(false)}>
168 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-16 left-[50%] w-[22rem] -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-[26rem] dark:border-neutral-700 starting:opacity-0">
169 <div class="mb-2 flex items-center gap-1 font-semibold">
170 <span class="iconify lucide--link"></span>
171 <span>Supported URLs</span>
172 </div>
173 <div class="mb-2 text-sm text-neutral-600 dark:text-neutral-400">
174 Links that will be parsed automatically, as long as all the data necessary is on the
175 URL.
176 </div>
177 <div class="flex flex-col gap-2 text-sm">
178 <For each={Object.entries(appName)}>
179 {([appView, name]) => {
180 return (
181 <div>
182 <p class="font-semibold">{name}</p>
183 <div class="grid grid-cols-2 gap-x-4 text-neutral-600 dark:text-neutral-400">
184 <For each={urls[appView]}>
185 {(url) => (
186 <a
187 href={`${url.startsWith("localhost:") ? "http://" : "https://"}${url}`}
188 target="_blank"
189 class="hover:underline active:underline"
190 >
191 {url}
192 </a>
193 )}
194 </For>
195 </div>
196 </div>
197 );
198 }}
199 </For>
200 </div>
201 </div>
202 </Modal>
203 <button
204 type="button"
205 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"
206 onClick={() => setOpenList(true)}
207 >
208 <span class="iconify lucide--help-circle"></span>
209 </button>
210 </>
211 );
212};
213
214export { Search, SearchButton };