forked from
pds.ls/pdsls
atproto explorer
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 };