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