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