forked from
pds.ls/pdsls
atproto explorer
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 { Modal } from "../components/modal";
9import { setPDS } from "../components/navbar";
10import Tooltip from "../components/tooltip";
11import { addToClipboard } from "../utils/copy";
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-800 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 break-words shadow-md transition-opacity duration-200 sm:max-w-[32rem] 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-800 dark:bg-dark-300 mb-2 flex w-full justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 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 <div class="flex gap-1">
137 <Tooltip text="Copy PDS">
138 <button
139 onClick={() => addToClipboard(params.pds)}
140 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"
141 >
142 <span class="iconify lucide--copy"></span>
143 </button>
144 </Tooltip>
145 <Tooltip text="Firehose">
146 <A
147 href={`/firehose?instance=wss://${params.pds}`}
148 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"
149 >
150 <span class="iconify lucide--radio-tower"></span>
151 </A>
152 </Tooltip>
153 </div>
154 </div>
155 <div class="flex flex-col gap-1 px-2">
156 <Show when={!location.hash || location.hash === "#repos"}>
157 <div class="flex flex-col divide-y-[0.5px] divide-neutral-300 dark:divide-neutral-700">
158 <For each={repos()}>{(repo) => <RepoCard {...repo} />}</For>
159 </div>
160 </Show>
161 <Show when={location.hash === "#info"}>
162 <Show when={version()}>
163 {(version) => (
164 <div class="flex items-baseline gap-x-1">
165 <span class="font-semibold">Version</span>
166 <span class="truncate text-sm">{version()}</span>
167 </div>
168 )}
169 </Show>
170 <Show when={serverInfos()}>
171 {(server) => (
172 <>
173 <div class="flex items-baseline gap-x-1">
174 <span class="font-semibold">DID</span>
175 <span class="truncate text-sm">{server().did}</span>
176 </div>
177 <Show when={server().inviteCodeRequired}>
178 <span class="font-semibold">Invite Code Required</span>
179 </Show>
180 <Show when={server().phoneVerificationRequired}>
181 <span class="font-semibold">Phone Verification Required</span>
182 </Show>
183 <Show when={server().availableUserDomains.length}>
184 <div class="flex flex-col">
185 <span class="font-semibold">Available User Domains</span>
186 <For each={server().availableUserDomains}>
187 {(domain) => <span class="text-sm wrap-anywhere">{domain}</span>}
188 </For>
189 </div>
190 </Show>
191 <Show when={server().links?.privacyPolicy}>
192 <div class="flex flex-col">
193 <span class="font-semibold">Privacy Policy</span>
194 <a
195 href={server().links?.privacyPolicy}
196 class="text-sm hover:underline"
197 target="_blank"
198 >
199 {server().links?.privacyPolicy}
200 </a>
201 </div>
202 </Show>
203 <Show when={server().links?.termsOfService}>
204 <div class="flex flex-col">
205 <span class="font-semibold">Terms of Service</span>
206 <a
207 href={server().links?.termsOfService}
208 class="text-sm hover:underline"
209 target="_blank"
210 >
211 {server().links?.termsOfService}
212 </a>
213 </div>
214 </Show>
215 <Show when={server().contact?.email}>
216 <div class="flex flex-col">
217 <span class="font-semibold">Contact</span>
218 <a href={`mailto:${server().contact?.email}`} class="text-sm hover:underline">
219 {server().contact?.email}
220 </a>
221 </div>
222 </Show>
223 </>
224 )}
225 </Show>
226 </Show>
227 </div>
228 </div>
229 <Show when={!location.hash || location.hash === "#repos"}>
230 <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-2">
231 <div class="flex flex-col items-center gap-1 pb-2">
232 <p>{repos()?.length} loaded</p>
233 <Show when={!response.loading && cursor()}>
234 <Button onClick={() => refetch()}>Load More</Button>
235 </Show>
236 <Show when={response.loading}>
237 <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span>
238 </Show>
239 </div>
240 </div>
241 </Show>
242 </Show>
243 );
244};
245
246export { PdsView };