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, AppUrl } from "../utils/app-urls";
6import { createDebouncedValue } from "../utils/hooks/debounced";
7
8export const [showSearch, setShowSearch] = createSignal(false);
9
10const SearchButton = () => {
11 onMount(() => window.addEventListener("keydown", keyEvent));
12 onCleanup(() => window.removeEventListener("keydown", keyEvent));
13
14 const keyEvent = (ev: KeyboardEvent) => {
15 if (document.querySelector("dialog")) return;
16
17 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") {
18 ev.preventDefault();
19 setShowSearch(!showSearch());
20 } else if (ev.key == "Escape") {
21 ev.preventDefault();
22 setShowSearch(false);
23 }
24 };
25
26 return (
27 <button
28 onclick={() => setShowSearch(!showSearch())}
29 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"}`}
30 >
31 <span class="iconify lucide--search"></span>
32 <Show when={!isTouchDevice}>
33 <kbd class="font-sans text-neutral-500 select-none dark:text-neutral-400">
34 {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K
35 </kbd>
36 </Show>
37 </button>
38 );
39};
40
41const Search = () => {
42 const navigate = useNavigate();
43 let searchInput!: HTMLInputElement;
44 const rpc = new Client({
45 handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
46 });
47
48 onMount(() => {
49 if (useLocation().pathname !== "/") searchInput.focus();
50 });
51
52 const fetchTypeahead = async (input: string) => {
53 if (!input.length) return [];
54 const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", {
55 params: { q: input, limit: 5 },
56 });
57 if (res.ok) {
58 return res.data.actors;
59 }
60 return [];
61 };
62
63 const [input, setInput] = createSignal<string>();
64 const [search] = createResource(createDebouncedValue(input, 250), fetchTypeahead);
65
66 const processInput = (input: string) => {
67 input = input.trim().replace(/^@/, "");
68 if (!input.length) return;
69 setShowSearch(false);
70 if (input === "me" && localStorage.getItem("lastSignedIn") !== null) {
71 navigate(`/at://${localStorage.getItem("lastSignedIn")}`);
72 } else if (search()?.length) {
73 navigate(`/at://${search()![0].did}`);
74 } else if (input.startsWith("https://") || input.startsWith("http://")) {
75 const hostLength = input.indexOf("/", 8);
76 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", "");
77
78 if (!(host in appList)) {
79 navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`);
80 } else {
81 const app = appList[host as AppUrl];
82 const path = input.slice(hostLength + 1).split("/");
83
84 const uri = appHandleLink[app](path);
85 navigate(`/${uri}`);
86 }
87 } else {
88 navigate(`/at://${input.replace("at://", "")}`);
89 }
90 setShowSearch(false);
91 };
92
93 return (
94 <form
95 class="relative w-full"
96 onsubmit={(e) => {
97 e.preventDefault();
98 processInput(searchInput.value);
99 }}
100 >
101 <label for="input" class="hidden">
102 PDS URL, AT URI, or handle
103 </label>
104 <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">
105 <span
106 class="iconify lucide--search text-neutral-500 dark:text-neutral-400"
107 onClick={() => searchInput.focus()}
108 ></span>
109 <input
110 type="text"
111 spellcheck={false}
112 placeholder="PDS URL, AT URI, DID, or handle"
113 ref={searchInput}
114 id="input"
115 class="grow select-none placeholder:text-sm focus:outline-none"
116 value={input() ?? ""}
117 onInput={(e) => setInput(e.currentTarget.value)}
118 />
119 <Show when={input()}>
120 <button
121 type="button"
122 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"
123 onClick={() => setInput(undefined)}
124 >
125 <span class="iconify lucide--x"></span>
126 </button>
127 </Show>
128 </div>
129 <Show when={search()?.length && input()}>
130 <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">
131 <For each={search()}>
132 {(actor) => (
133 <A
134 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"
135 href={`/at://${actor.did}`}
136 onClick={() => setShowSearch(false)}
137 >
138 <img
139 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")}
140 class="size-8 rounded-full"
141 />
142 <span>{actor.handle}</span>
143 </A>
144 )}
145 </For>
146 </div>
147 </Show>
148 </form>
149 );
150};
151
152export { Search, SearchButton };