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