atproto explorer pdsls.dev
atproto tool
1import { ComAtprotoServerDescribeServer, ComAtprotoSyncListRepos } from "@atcute/atproto"; 2import { Client, CredentialManager } from "@atcute/client"; 3import { InferXRPCBodyOutput } from "@atcute/lexicons"; 4import * as TID from "@atcute/tid"; 5import { A, useLocation, useParams } from "@solidjs/router"; 6import { createResource, createSignal, For, Show } from "solid-js"; 7import { Button } from "../components/button"; 8import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown"; 9import { Modal } from "../components/modal"; 10import { setPDS } from "../components/navbar"; 11import Tooltip from "../components/tooltip"; 12import { localDateFromTimestamp } from "../utils/date"; 13 14const LIMIT = 1000; 15 16const PdsView = () => { 17 const params = useParams(); 18 const location = useLocation(); 19 const [version, setVersion] = createSignal<string>(); 20 const [serverInfos, setServerInfos] = 21 createSignal<InferXRPCBodyOutput<ComAtprotoServerDescribeServer.mainSchema["output"]>>(); 22 const [cursor, setCursor] = createSignal<string>(); 23 setPDS(params.pds); 24 const pds = params.pds.startsWith("localhost") ? `http://${params.pds}` : `https://${params.pds}`; 25 const rpc = new Client({ handler: new CredentialManager({ service: pds }) }); 26 27 const getVersion = async () => { 28 // @ts-expect-error: undocumented endpoint 29 const res = await rpc.get("_health", {}); 30 setVersion((res.data as any).version); 31 }; 32 33 const describeServer = async () => { 34 const res = await rpc.get("com.atproto.server.describeServer"); 35 if (!res.ok) console.error(res.data.error); 36 else setServerInfos(res.data); 37 }; 38 39 const fetchRepos = async () => { 40 getVersion(); 41 describeServer(); 42 const res = await rpc.get("com.atproto.sync.listRepos", { 43 params: { limit: LIMIT, cursor: cursor() }, 44 }); 45 if (!res.ok) throw new Error(res.data.error); 46 setCursor(res.data.repos.length < LIMIT ? undefined : res.data.cursor); 47 setRepos(repos()?.concat(res.data.repos) ?? res.data.repos); 48 return res.data; 49 }; 50 51 const [response, { refetch }] = createResource(fetchRepos); 52 const [repos, setRepos] = createSignal<ComAtprotoSyncListRepos.Repo[]>(); 53 54 const RepoCard = (repo: ComAtprotoSyncListRepos.Repo) => { 55 const [openInfo, setOpenInfo] = createSignal(false); 56 57 return ( 58 <div class="flex items-center"> 59 <A 60 href={`/at://${repo.did}`} 61 class="grow truncate rounded py-0.5 font-mono hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 62 > 63 {repo.did} 64 </A> 65 <Show when={!repo.active}> 66 <Tooltip text={repo.status ?? "Unknown status"}> 67 <span class="iconify lucide--unplug text-red-500 dark:text-red-400"></span> 68 </Tooltip> 69 </Show> 70 <button 71 onclick={() => setOpenInfo(true)} 72 class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 73 > 74 <span class="iconify lucide--info"></span> 75 </button> 76 <Modal open={openInfo()} onClose={() => setOpenInfo(false)}> 77 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-max max-w-full -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-3 wrap-break-word shadow-md transition-opacity duration-200 sm:max-w-lg dark:border-neutral-700 starting:opacity-0"> 78 <div class="mb-1 flex justify-between gap-2"> 79 <div class="flex items-center gap-1"> 80 <span class="iconify lucide--info"></span> 81 <span class="font-semibold">{repo.did}</span> 82 </div> 83 <button 84 onclick={() => setOpenInfo(false)} 85 class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 86 > 87 <span class="iconify lucide--x"></span> 88 </button> 89 </div> 90 <div class="flex flex-col text-sm"> 91 <span> 92 Head: <span class="text-xs">{repo.head}</span> 93 </span> 94 <Show when={TID.validate(repo.rev)}> 95 <span> 96 Rev: {repo.rev} ({localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000)}) 97 </span> 98 </Show> 99 <Show when={repo.active !== undefined}> 100 <span>Active: {repo.active ? "true" : "false"}</span> 101 </Show> 102 <Show when={repo.status}> 103 <span>Status: {repo.status}</span> 104 </Show> 105 </div> 106 </div> 107 </Modal> 108 </div> 109 ); 110 }; 111 112 const Tab = (props: { tab: "repos" | "info"; label: string }) => ( 113 <div class="flex items-center gap-0.5"> 114 <A 115 classList={{ 116 "flex items-center gap-1 border-b-2": true, 117 "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 118 (!!location.hash && location.hash !== `#${props.tab}`) || 119 (!location.hash && props.tab !== "repos"), 120 }} 121 href={`/${params.pds}#${props.tab}`} 122 > 123 {props.label} 124 </A> 125 </div> 126 ); 127 128 return ( 129 <Show when={repos() || response()}> 130 <div class="flex w-full flex-col"> 131 <div class="dark:shadow-dark-700 dark:bg-dark-300 mb-2 flex w-full justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-sm shadow-xs dark:border-neutral-700"> 132 <div class="flex gap-3"> 133 <Tab tab="repos" label="Repositories" /> 134 <Tab tab="info" label="Info" /> 135 </div> 136 <MenuProvider> 137 <DropdownMenu 138 icon="lucide--ellipsis-vertical" 139 buttonClass="rounded-sm p-1.5" 140 menuClass="top-9 p-2 text-sm" 141 > 142 <CopyMenu content={params.pds} label="Copy PDS" icon="lucide--copy" /> 143 <NavMenu 144 href={`/firehose?instance=wss://${params.pds}`} 145 label="Firehose" 146 icon="lucide--radio-tower" 147 /> 148 </DropdownMenu> 149 </MenuProvider> 150 </div> 151 <div class="flex flex-col gap-1 px-2"> 152 <Show when={!location.hash || location.hash === "#repos"}> 153 <div class="flex flex-col divide-y-[0.5px] divide-neutral-300 dark:divide-neutral-700"> 154 <For each={repos()}>{(repo) => <RepoCard {...repo} />}</For> 155 </div> 156 </Show> 157 <Show when={location.hash === "#info"}> 158 <Show when={version()}> 159 {(version) => ( 160 <div class="flex items-baseline gap-x-1"> 161 <span class="font-semibold">Version</span> 162 <span class="truncate text-sm">{version()}</span> 163 </div> 164 )} 165 </Show> 166 <Show when={serverInfos()}> 167 {(server) => ( 168 <> 169 <div class="flex items-baseline gap-x-1"> 170 <span class="font-semibold">DID</span> 171 <span class="truncate text-sm">{server().did}</span> 172 </div> 173 <Show when={server().inviteCodeRequired}> 174 <span class="font-semibold">Invite Code Required</span> 175 </Show> 176 <Show when={server().phoneVerificationRequired}> 177 <span class="font-semibold">Phone Verification Required</span> 178 </Show> 179 <Show when={server().availableUserDomains.length}> 180 <div class="flex flex-col"> 181 <span class="font-semibold">Available User Domains</span> 182 <For each={server().availableUserDomains}> 183 {(domain) => <span class="text-sm wrap-anywhere">{domain}</span>} 184 </For> 185 </div> 186 </Show> 187 <Show when={server().links?.privacyPolicy}> 188 <div class="flex flex-col"> 189 <span class="font-semibold">Privacy Policy</span> 190 <a 191 href={server().links?.privacyPolicy} 192 class="text-sm hover:underline" 193 target="_blank" 194 rel="noopener" 195 > 196 {server().links?.privacyPolicy} 197 </a> 198 </div> 199 </Show> 200 <Show when={server().links?.termsOfService}> 201 <div class="flex flex-col"> 202 <span class="font-semibold">Terms of Service</span> 203 <a 204 href={server().links?.termsOfService} 205 class="text-sm hover:underline" 206 target="_blank" 207 rel="noopener" 208 > 209 {server().links?.termsOfService} 210 </a> 211 </div> 212 </Show> 213 <Show when={server().contact?.email}> 214 <div class="flex flex-col"> 215 <span class="font-semibold">Contact</span> 216 <a href={`mailto:${server().contact?.email}`} class="text-sm hover:underline"> 217 {server().contact?.email} 218 </a> 219 </div> 220 </Show> 221 </> 222 )} 223 </Show> 224 </Show> 225 </div> 226 </div> 227 <Show when={!location.hash || location.hash === "#repos"}> 228 <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-2"> 229 <div class="flex flex-col items-center gap-1 pb-2"> 230 <p>{repos()?.length} loaded</p> 231 <Show when={!response.loading && cursor()}> 232 <Button onClick={() => refetch()}>Load More</Button> 233 </Show> 234 <Show when={response.loading}> 235 <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 236 </Show> 237 </div> 238 </div> 239 </Show> 240 </Show> 241 ); 242}; 243 244export { PdsView };