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