forked from pdsls.dev/pdsls
atproto explorer
at main 8.1 kB view raw
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 };