atproto explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 269 lines 12 kB view raw
1import { ComAtprotoServerDescribeServer, ComAtprotoSyncListRepos } from "@atcute/atproto"; 2import { Client, simpleFetchHandler } 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 { Modal } from "../components/modal"; 9import { setPDS } from "../components/navbar"; 10import Tooltip from "../components/tooltip"; 11import { resolveDidDoc } from "../utils/api"; 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: simpleFetchHandler({ 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 const [handle, setHandle] = createSignal<string>(); 58 59 const fetchHandle = async () => { 60 try { 61 const doc = await resolveDidDoc(repo.did); 62 const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://")); 63 if (aka) setHandle(aka.replace("at://", "")); 64 } catch {} 65 }; 66 67 return ( 68 <div class="flex items-center gap-0.5"> 69 <A 70 href={`/at://${repo.did}`} 71 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" 72 > 73 {repo.did} 74 </A> 75 <Show when={!repo.active}> 76 <Tooltip text={repo.status ?? "Unknown status"}> 77 <span class="iconify lucide--unplug text-red-500 dark:text-red-400"></span> 78 </Tooltip> 79 </Show> 80 <button 81 onclick={() => { 82 setOpenInfo(true); 83 if (!handle()) fetchHandle(); 84 }} 85 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" 86 > 87 <span class="iconify lucide--info text-neutral-600 dark:text-neutral-400"></span> 88 </button> 89 <Modal open={openInfo()} onClose={() => setOpenInfo(false)}> 90 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-max max-w-[90vw] -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"> 91 <div class="mb-2 flex items-center justify-between gap-4"> 92 <p class="truncate font-semibold">{repo.did}</p> 93 <button 94 onclick={() => setOpenInfo(false)} 95 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" 96 > 97 <span class="iconify lucide--x"></span> 98 </button> 99 </div> 100 <div class="grid grid-cols-[auto_1fr] items-baseline gap-x-1 gap-y-0.5 text-sm"> 101 <span class="font-medium">Handle:</span> 102 <span class="text-neutral-700 dark:text-neutral-300">{handle()}</span> 103 <span class="font-medium">Head:</span> 104 <span class="wrap-anywhere text-neutral-700 dark:text-neutral-300">{repo.head}</span> 105 106 <Show when={TID.validate(repo.rev)}> 107 <span class="font-medium">Rev:</span> 108 <div class="flex gap-1"> 109 <span class="text-neutral-700 dark:text-neutral-300">{repo.rev}</span> 110 <span class="text-neutral-600 dark:text-neutral-400">·</span> 111 <span class="text-neutral-600 dark:text-neutral-400"> 112 {localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000)} 113 </span> 114 </div> 115 </Show> 116 117 <Show when={repo.active !== undefined}> 118 <span class="font-medium">Active:</span> 119 <span 120 class={`iconify self-center ${ 121 repo.active ? 122 "lucide--check text-green-500 dark:text-green-400" 123 : "lucide--x text-red-500 dark:text-red-400" 124 }`} 125 ></span> 126 </Show> 127 128 <Show when={repo.status}> 129 <span class="font-medium">Status:</span> 130 <span class="text-neutral-700 dark:text-neutral-300">{repo.status}</span> 131 </Show> 132 </div> 133 </div> 134 </Modal> 135 </div> 136 ); 137 }; 138 139 const Tab = (props: { tab: "repos" | "info" | "firehose"; label: string }) => ( 140 <A 141 classList={{ 142 "border-b-2 font-medium": true, 143 "border-transparent dark:text-neutral-300/80 text-neutral-600 hover:border-neutral-600 dark:hover:border-neutral-300/80": 144 (!!location.hash && location.hash !== `#${props.tab}`) || 145 (!location.hash && props.tab !== "repos"), 146 }} 147 href={ 148 props.tab === "firehose" ? 149 `/firehose?instance=wss://${params.pds}` 150 : `/${params.pds}#${props.tab}` 151 } 152 > 153 {props.label} 154 </A> 155 ); 156 157 return ( 158 <Show when={repos() || response()}> 159 <div class="flex w-full flex-col px-2"> 160 <div class="mb-3 flex gap-4 text-sm sm:text-base"> 161 <Tab tab="repos" label="Repositories" /> 162 <Tab tab="info" label="Info" /> 163 <Tab tab="firehose" label="Firehose" /> 164 </div> 165 <Show when={!location.hash || location.hash === "#repos"}> 166 <div class="flex flex-col divide-y-[0.5px] divide-neutral-300 pb-20 dark:divide-neutral-700"> 167 <For each={repos()}>{(repo) => <RepoCard {...repo} />}</For> 168 </div> 169 </Show> 170 <div class="flex flex-col gap-2"> 171 <Show when={location.hash === "#info"}> 172 <Show when={version()}> 173 {(version) => ( 174 <div class="flex flex-col"> 175 <span class="font-semibold">Version</span> 176 <span class="text-sm text-neutral-700 dark:text-neutral-300">{version()}</span> 177 </div> 178 )} 179 </Show> 180 <Show when={serverInfos()}> 181 {(server) => ( 182 <> 183 <div class="flex flex-col"> 184 <span class="font-semibold">DID</span> 185 <span class="text-sm">{server().did}</span> 186 </div> 187 <div class="flex items-center gap-1"> 188 <span class="font-semibold">Invite Code Required</span> 189 <span 190 classList={{ 191 "iconify lucide--check text-green-500 dark:text-green-400": 192 server().inviteCodeRequired === true, 193 "iconify lucide--x text-red-500 dark:text-red-400": 194 !server().inviteCodeRequired, 195 }} 196 ></span> 197 </div> 198 <Show when={server().phoneVerificationRequired}> 199 <div class="flex items-center gap-1"> 200 <span class="font-semibold">Phone Verification Required</span> 201 <span class="iconify lucide--check text-green-500 dark:text-green-400"></span> 202 </div> 203 </Show> 204 <Show when={server().availableUserDomains.length}> 205 <div class="flex flex-col"> 206 <span class="font-semibold">Available User Domains</span> 207 <For each={server().availableUserDomains}> 208 {(domain) => <span class="text-sm wrap-anywhere">{domain}</span>} 209 </For> 210 </div> 211 </Show> 212 <Show when={server().links?.privacyPolicy}> 213 <div class="flex flex-col"> 214 <span class="font-semibold">Privacy Policy</span> 215 <a 216 href={server().links?.privacyPolicy} 217 class="text-sm hover:underline" 218 target="_blank" 219 rel="noopener" 220 > 221 {server().links?.privacyPolicy} 222 </a> 223 </div> 224 </Show> 225 <Show when={server().links?.termsOfService}> 226 <div class="flex flex-col"> 227 <span class="font-semibold">Terms of Service</span> 228 <a 229 href={server().links?.termsOfService} 230 class="text-sm hover:underline" 231 target="_blank" 232 rel="noopener" 233 > 234 {server().links?.termsOfService} 235 </a> 236 </div> 237 </Show> 238 <Show when={server().contact?.email}> 239 <div class="flex flex-col"> 240 <span class="font-semibold">Contact</span> 241 <a href={`mailto:${server().contact?.email}`} class="text-sm hover:underline"> 242 {server().contact?.email} 243 </a> 244 </div> 245 </Show> 246 </> 247 )} 248 </Show> 249 </Show> 250 </div> 251 </div> 252 <Show when={!location.hash || location.hash === "#repos"}> 253 <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 pt-2 pb-4"> 254 <div class="flex flex-col items-center gap-1 pb-2"> 255 <p>{repos()?.length} loaded</p> 256 <Show when={!response.loading && cursor()}> 257 <Button onClick={() => refetch()}>Load More</Button> 258 </Show> 259 <Show when={response.loading}> 260 <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 261 </Show> 262 </div> 263 </div> 264 </Show> 265 </Show> 266 ); 267}; 268 269export { PdsView };