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-[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">
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 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 };