atproto explorer

search shortcut

+190 -152
+1 -1
src/components/navbar.tsx
··· 52 52 }); 53 53 54 54 return ( 55 - <nav class="mt-4 flex w-[22rem] flex-col text-sm wrap-anywhere sm:w-[24rem]"> 55 + <nav class="flex w-[22rem] flex-col text-sm wrap-anywhere sm:w-[24rem]"> 56 56 <div class="relative flex items-center justify-between gap-1"> 57 57 <div class="flex min-h-[1.25rem] basis-full items-center gap-2"> 58 58 <Tooltip text="PDS">
+48 -29
src/components/search.tsx
··· 1 - import { Handle } from "@atcute/lexicons"; 2 - import { useNavigate } from "@solidjs/router"; 3 - import { createSignal, Show } from "solid-js"; 4 - import { resolveHandle } from "../utils/api.js"; 1 + import { useLocation, useNavigate } from "@solidjs/router"; 2 + import { createSignal, onCleanup, onMount, Show } from "solid-js"; 3 + import { isTouchDevice } from "../layout"; 4 + 5 + export const [showSearch, setShowSearch] = createSignal(false); 6 + 7 + const SearchButton = () => { 8 + onMount(() => window.addEventListener("keydown", keyEvent)); 9 + onCleanup(() => window.removeEventListener("keydown", keyEvent)); 10 + 11 + const keyEvent = (ev: KeyboardEvent) => { 12 + if (document.querySelector("dialog")) return; 13 + 14 + if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") { 15 + ev.preventDefault(); 16 + setShowSearch(!showSearch()); 17 + } else if (ev.key == "Escape") { 18 + ev.preventDefault(); 19 + setShowSearch(false); 20 + } 21 + }; 22 + 23 + return ( 24 + <button 25 + onclick={() => setShowSearch(!showSearch())} 26 + class={`flex items-center gap-0.5 rounded-lg ${isTouchDevice ? "p-1 text-xl hover:bg-neutral-200 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-700" : "bg-neutral-200 p-1.5 text-xs hover:bg-neutral-300 active:bg-neutral-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:active:bg-neutral-600"}`} 27 + > 28 + <span class="iconify lucide--search"></span> 29 + <Show when={!isTouchDevice}> 30 + <kbd class="font-sans text-neutral-500 dark:text-neutral-400"> 31 + {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K 32 + </kbd> 33 + </Show> 34 + </button> 35 + ); 36 + }; 5 37 6 38 const Search = () => { 7 39 const navigate = useNavigate(); 8 40 let searchInput!: HTMLInputElement; 9 - const [loading, setLoading] = createSignal(false); 41 + 42 + onMount(() => { 43 + if (useLocation().pathname !== "/") searchInput.focus(); 44 + }); 10 45 11 46 const processInput = async (input: string) => { 12 47 (document.getElementById("uriForm") as HTMLFormElement).reset(); ··· 16 51 .replace(/^\u202a/, "") 17 52 .replace(/^@/, ""); 18 53 if (!input.length) return; 19 - (document.getElementById("input") as HTMLInputElement).blur(); 54 + setShowSearch(false); 20 55 if ( 21 56 !input.startsWith("https://bsky.app/") && 22 57 !input.startsWith("https://deer.social/") && ··· 32 67 .replace("https://bsky.app/profile/", "") 33 68 .replace("/post/", "/app.bsky.feed.post/"); 34 69 const uriParts = uri.split("/"); 35 - const actor = uriParts[0]; 36 - let did = ""; 37 - try { 38 - setLoading(true); 39 - did = uri.startsWith("did:") ? actor : await resolveHandle(actor as Handle); 40 - setLoading(false); 41 - } catch { 42 - setLoading(false); 43 - navigate(`/${actor}`); 44 - return; 45 - } 46 - navigate(`/at://${did}${uriParts.length > 1 ? `/${uriParts.slice(1).join("/")}` : ""}`); 70 + navigate(`/at://${uriParts[0]}${uriParts.length > 1 ? `/${uriParts.slice(1).join("/")}` : ""}`); 47 71 }; 48 72 49 73 return ( ··· 65 89 id="input" 66 90 class="grow placeholder:text-sm focus:outline-none" 67 91 /> 68 - <Show when={loading()}> 69 - <span class="iconify lucide--loader-circle animate-spin text-lg"></span> 70 - </Show> 71 - <Show when={!loading()}> 72 - <button 73 - type="submit" 74 - class="iconify lucide--arrow-right text-lg text-neutral-500 dark:text-neutral-400" 75 - onclick={() => processInput(searchInput.value)} 76 - ></button> 77 - </Show> 92 + <button 93 + type="submit" 94 + class="iconify lucide--arrow-right text-lg text-neutral-500 dark:text-neutral-400" 95 + onclick={() => processInput(searchInput.value)} 96 + ></button> 78 97 </div> 79 98 </div> 80 99 </form> 81 100 ); 82 101 }; 83 102 84 - export { Search }; 103 + export { Search, SearchButton };
+1 -2
src/components/tooltip.tsx
··· 1 1 import { JSX, Show } from "solid-js"; 2 - 3 - const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 1; 2 + import { isTouchDevice } from "../layout"; 4 3 5 4 const Tooltip = (props: { text: string; children: JSX.Element }) => ( 6 5 <div class="group/tooltip relative flex items-center">
+8 -3
src/layout.tsx
··· 7 7 import { DropdownMenu, MenuProvider, NavMenu } from "./components/dropdown.jsx"; 8 8 import { agent } from "./components/login.jsx"; 9 9 import { NavBar } from "./components/navbar.jsx"; 10 - import { Search } from "./components/search.jsx"; 10 + import { Search, SearchButton, showSearch } from "./components/search.jsx"; 11 11 import { themeEvent, ThemeSelection } from "./components/theme.jsx"; 12 12 import { resolveHandle } from "./utils/api.js"; 13 + 14 + export const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 1; 13 15 14 16 export const [notif, setNotif] = createSignal<{ 15 17 show: boolean; ··· 57 59 <span>PDSls</span> 58 60 </A> 59 61 <div class="relative flex items-center gap-1"> 62 + <Show when={location.pathname !== "/"}> 63 + <SearchButton /> 64 + </Show> 60 65 <Show when={agent()}> 61 66 <RecordEditor create={true} /> 62 67 </Show> ··· 75 80 </MenuProvider> 76 81 </div> 77 82 </header> 78 - <div class="mb-4 flex max-w-full min-w-[22rem] flex-col items-center text-pretty sm:min-w-[24rem] md:max-w-[48rem]"> 79 - <Show when={!["/jetstream", "/firehose", "/settings"].includes(location.pathname)}> 83 + <div class="flex max-w-full min-w-[22rem] flex-col items-center gap-4 text-pretty sm:min-w-[24rem] md:max-w-[48rem]"> 84 + <Show when={showSearch() || location.pathname === "/"}> 80 85 <Search /> 81 86 </Show> 82 87 <Show when={props.params.pds}>
+107 -105
src/views/collection.tsx
··· 159 159 160 160 return ( 161 161 <Show when={records.length || response()}> 162 - <div class="dark:bg-dark-500/70 sticky top-0 z-5 flex w-screen flex-col items-center justify-center gap-2 bg-neutral-100/70 py-3 backdrop-blur-xs"> 163 - <div class="flex w-[22rem] items-center gap-2 sm:w-[24rem]"> 164 - <Show when={agent() && agent()?.sub === did}> 165 - <div class="flex items-center gap-x-2"> 166 - <Tooltip 167 - text={batchDelete() ? "Cancel" : "Delete"} 168 - children={ 169 - <button 170 - onclick={() => { 171 - setRecords( 172 - { from: 0, to: untrack(() => records.length) - 1 }, 173 - "toDelete", 174 - false, 175 - ); 176 - setLastSelected(undefined); 177 - setBatchDelete(!batchDelete()); 178 - }} 179 - class="flex items-center" 180 - > 181 - <span 182 - class={`iconify text-lg ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `} 183 - ></span> 184 - </button> 185 - } 186 - /> 187 - <Show when={batchDelete()}> 162 + <div class="flex w-full flex-col items-center"> 163 + <div class="dark:bg-dark-500/70 sticky top-0 z-5 flex w-screen flex-col items-center justify-center gap-2 bg-neutral-100/70 pt-1 pb-3 backdrop-blur-xs"> 164 + <div class="flex w-[22rem] items-center gap-2 sm:w-[24rem]"> 165 + <Show when={agent() && agent()?.sub === did}> 166 + <div class="flex items-center gap-x-2"> 188 167 <Tooltip 189 - text="Select all" 190 - children={ 191 - <button onclick={() => selectAll()} class="flex items-center"> 192 - <span class="iconify lucide--copy-check text-lg"></span> 193 - </button> 194 - } 195 - /> 196 - <Tooltip 197 - text="Confirm" 168 + text={batchDelete() ? "Cancel" : "Delete"} 198 169 children={ 199 - <button onclick={() => deleteRecords()} class="flex items-center"> 200 - <span class="iconify lucide--trash-2 text-lg text-red-500 dark:text-red-400"></span> 170 + <button 171 + onclick={() => { 172 + setRecords( 173 + { from: 0, to: untrack(() => records.length) - 1 }, 174 + "toDelete", 175 + false, 176 + ); 177 + setLastSelected(undefined); 178 + setBatchDelete(!batchDelete()); 179 + }} 180 + class="flex items-center" 181 + > 182 + <span 183 + class={`iconify text-lg ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `} 184 + ></span> 201 185 </button> 202 186 } 203 187 /> 204 - </Show> 188 + <Show when={batchDelete()}> 189 + <Tooltip 190 + text="Select all" 191 + children={ 192 + <button onclick={() => selectAll()} class="flex items-center"> 193 + <span class="iconify lucide--copy-check text-lg"></span> 194 + </button> 195 + } 196 + /> 197 + <Tooltip 198 + text="Confirm" 199 + children={ 200 + <button onclick={() => deleteRecords()} class="flex items-center"> 201 + <span class="iconify lucide--trash-2 text-lg text-red-500 dark:text-red-400"></span> 202 + </button> 203 + } 204 + /> 205 + </Show> 206 + </div> 207 + </Show> 208 + <TextInput 209 + placeholder="Filter by substring" 210 + class="w-full" 211 + onInput={(e) => setFilter(e.currentTarget.value)} 212 + /> 213 + </div> 214 + <Show when={records.length > 1}> 215 + <div class="flex w-[22rem] items-center justify-between gap-x-2 sm:w-[24rem]"> 216 + <Button 217 + onClick={() => { 218 + setReverse(!reverse()); 219 + setRecords([]); 220 + setCursor(undefined); 221 + refetch(); 222 + }} 223 + > 224 + <span 225 + class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"} text-sm`} 226 + ></span> 227 + Reverse 228 + </Button> 229 + <div> 230 + <Show when={batchDelete()}> 231 + <span>{records.filter((rec) => rec.toDelete).length}</span> 232 + <span>/</span> 233 + </Show> 234 + <span>{records.length} records</span> 235 + </div> 236 + <div class="flex w-[5rem] items-center justify-end"> 237 + <Show when={cursor()}> 238 + <Show when={!response.loading}> 239 + <Button onClick={() => refetch()}>Load More</Button> 240 + </Show> 241 + <Show when={response.loading}> 242 + <div class="iconify lucide--loader-circle w-[5rem] animate-spin text-xl" /> 243 + </Show> 244 + </Show> 245 + </div> 205 246 </div> 206 247 </Show> 207 - <TextInput 208 - placeholder="Filter by substring" 209 - class="w-full" 210 - onInput={(e) => setFilter(e.currentTarget.value)} 211 - /> 212 248 </div> 213 - <Show when={records.length > 1}> 214 - <div class="flex w-[22rem] items-center justify-between gap-x-2 sm:w-[24rem]"> 215 - <Button 216 - onClick={() => { 217 - setReverse(!reverse()); 218 - setRecords([]); 219 - setCursor(undefined); 220 - refetch(); 221 - }} 222 - > 223 - <span 224 - class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"} text-sm`} 225 - ></span> 226 - Reverse 227 - </Button> 228 - <div> 229 - <Show when={batchDelete()}> 230 - <span>{records.filter((rec) => rec.toDelete).length}</span> 231 - <span>/</span> 232 - </Show> 233 - <span>{records.length} records</span> 234 - </div> 235 - <div class="flex w-[5rem] items-center justify-end"> 236 - <Show when={cursor()}> 237 - <Show when={!response.loading}> 238 - <Button onClick={() => refetch()}>Load More</Button> 249 + <div class="flex max-w-full flex-col font-mono"> 250 + <For 251 + each={records.filter((rec) => 252 + filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true, 253 + )} 254 + > 255 + {(record, index) => ( 256 + <> 257 + <Show when={batchDelete()}> 258 + <label 259 + class="flex items-center gap-1 select-none" 260 + onclick={(e) => handleSelectionClick(e, index())} 261 + > 262 + <input 263 + type="checkbox" 264 + checked={record.toDelete} 265 + onchange={(e) => setRecords(index(), "toDelete", e.currentTarget.checked)} 266 + /> 267 + <RecordLink record={record} /> 268 + </label> 239 269 </Show> 240 - <Show when={response.loading}> 241 - <div class="iconify lucide--loader-circle w-[5rem] animate-spin text-xl" /> 270 + <Show when={!batchDelete()}> 271 + <A href={`/at://${did}/${params.collection}/${record.rkey}`}> 272 + <RecordLink record={record} /> 273 + </A> 242 274 </Show> 243 - </Show> 244 - </div> 245 - </div> 246 - </Show> 247 - </div> 248 - <div class="flex max-w-full flex-col font-mono"> 249 - <For 250 - each={records.filter((rec) => 251 - filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true, 252 - )} 253 - > 254 - {(record, index) => ( 255 - <> 256 - <Show when={batchDelete()}> 257 - <label 258 - class="flex items-center gap-1 select-none" 259 - onclick={(e) => handleSelectionClick(e, index())} 260 - > 261 - <input 262 - type="checkbox" 263 - checked={record.toDelete} 264 - onchange={(e) => setRecords(index(), "toDelete", e.currentTarget.checked)} 265 - /> 266 - <RecordLink record={record} /> 267 - </label> 268 - </Show> 269 - <Show when={!batchDelete()}> 270 - <A href={`/at://${did}/${params.collection}/${record.rkey}`}> 271 - <RecordLink record={record} /> 272 - </A> 273 - </Show> 274 - </> 275 - )} 276 - </For> 275 + </> 276 + )} 277 + </For> 278 + </div> 277 279 </div> 278 280 </Show> 279 281 );
+2 -2
src/views/home.tsx
··· 2 2 3 3 const Home = () => { 4 4 return ( 5 - <div class="mt-4 flex w-[22rem] flex-col gap-3 break-words sm:w-[24rem]"> 5 + <div class="flex w-[22rem] flex-col gap-4 break-words sm:w-[24rem]"> 6 6 <div> 7 7 <div> 8 - <span class="font-semibold">AT Protocol Explorer</span> 8 + <span class="text-lg font-semibold">AT Protocol Explorer</span> 9 9 </div> 10 10 <div class="flex items-center gap-1"> 11 11 <div class="iconify lucide--search" />
+8 -5
src/views/labels.tsx
··· 59 59 }; 60 60 61 61 return ( 62 - <> 63 - <form class="mt-3 flex flex-col items-center gap-y-1" onsubmit={(e) => e.preventDefault()}> 62 + <div class="flex w-full flex-col items-center"> 63 + <form 64 + class="flex w-[22rem] flex-col items-center gap-y-1 sm:w-[24rem]" 65 + onsubmit={(e) => e.preventDefault()} 66 + > 64 67 <div class="w-full"> 65 68 <label for="patterns" class="ml-0.5 text-sm"> 66 69 URI Patterns (comma-separated) 67 70 </label> 68 71 </div> 69 - <div class="flex w-[22rem] items-center gap-x-1 sm:w-[24rem]"> 72 + <div class="flex w-full items-center gap-x-1"> 70 73 <textarea 71 74 id="patterns" 72 75 name="patterns" ··· 120 123 </div> 121 124 </div> 122 125 <Show when={labels().length}> 123 - <div class="flex w-[22rem] flex-col gap-2 divide-y-[0.5px] divide-neutral-400 text-sm wrap-anywhere whitespace-pre-wrap sm:min-w-[24rem] dark:divide-neutral-600"> 126 + <div class="flex max-w-full min-w-[22rem] flex-col gap-2 divide-y-[0.5px] divide-neutral-400 text-sm wrap-anywhere whitespace-pre-wrap sm:min-w-[24rem] dark:divide-neutral-600"> 124 127 <For each={filterLabels()}> 125 128 {(label) => ( 126 129 <div class="flex items-center justify-between gap-2 pb-2"> ··· 169 172 <Show when={!labels().length && !response.loading && searchParams.uriPatterns}> 170 173 <div class="mt-2">No results</div> 171 174 </Show> 172 - </> 175 + </div> 173 176 ); 174 177 }; 175 178
+1 -1
src/views/pds.tsx
··· 48 48 49 49 return ( 50 50 <Show when={repos() || response()}> 51 - <div class="mt-3 flex w-[22rem] flex-col sm:w-[24rem]"> 51 + <div class="flex w-[22rem] flex-col sm:w-[24rem]"> 52 52 <Show when={version()}> 53 53 {(version) => ( 54 54 <div class="flex items-baseline gap-x-1">
+1 -1
src/views/record.tsx
··· 120 120 return ( 121 121 <Show when={record()} keyed> 122 122 <div class="flex w-full flex-col items-center"> 123 - <div class="dark:shadow-dark-800 dark:bg-dark-300 my-3 flex w-[22rem] justify-between rounded-lg bg-neutral-50 px-2 py-1.5 shadow-sm sm:w-[24rem]"> 123 + <div class="dark:shadow-dark-800 dark:bg-dark-300 mb-3 flex w-[22rem] justify-between rounded-lg bg-neutral-50 px-2 py-1.5 shadow-sm sm:w-[24rem]"> 124 124 <div class="flex gap-3 text-sm"> 125 125 <A 126 126 classList={{
+13 -3
src/views/repo.tsx
··· 7 7 processIndexedEntryLog, 8 8 } from "@atcute/did-plc"; 9 9 import { DidDocument } from "@atcute/identity"; 10 - import { ActorIdentifier } from "@atcute/lexicons"; 10 + import { ActorIdentifier, Handle } from "@atcute/lexicons"; 11 + import { resolveHandle } from "@atcute/oauth-browser-client"; 11 12 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 12 13 import { createResource, createSignal, ErrorBoundary, For, Show, Suspense } from "solid-js"; 13 14 import { Backlinks } from "../components/backlinks.jsx"; ··· 184 185 ); 185 186 186 187 const fetchRepo = async () => { 187 - pds = await resolvePDS(did); 188 + try { 189 + pds = await resolvePDS(did); 190 + } catch { 191 + try { 192 + const did = await resolveHandle(params.repo as Handle); 193 + navigate(location.pathname.replace(params.repo, did)); 194 + } catch { 195 + navigate(`/${did}`); 196 + } 197 + } 188 198 setDidDoc(didDocCache[did] as DidDocument); 189 199 190 200 rpc = new Client({ handler: new CredentialManager({ service: pds }) }); ··· 257 267 258 268 return ( 259 269 <Show when={repo()}> 260 - <div class="mt-3 flex w-[22rem] flex-col gap-2 break-words sm:w-[24rem]"> 270 + <div class="flex w-[22rem] flex-col gap-2 break-words sm:w-[24rem]"> 261 271 <Show when={error()}> 262 272 <div class="rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-200 dark:text-red-600"> 263 273 {error()}