forked from
pds.ls/pdsls
atproto explorer
1import { Client, CredentialManager } from "@atcute/client";
2import { lexiconDoc } from "@atcute/lexicon-doc";
3import { ActorIdentifier, is, Nsid, ResourceUri } from "@atcute/lexicons";
4import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
5import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js";
6import { Backlinks } from "../components/backlinks.jsx";
7import { Button } from "../components/button.jsx";
8import { RecordEditor, setPlaceholder } from "../components/create.jsx";
9import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx";
10import { JSONValue } from "../components/json.jsx";
11import { agent } from "../components/login.jsx";
12import { Modal } from "../components/modal.jsx";
13import { pds } from "../components/navbar.jsx";
14import Tooltip from "../components/tooltip.jsx";
15import { setNotif } from "../layout.jsx";
16import { didDocCache, resolveLexiconAuthority, resolvePDS } from "../utils/api.js";
17import { AtUri, uriTemplates } from "../utils/templates.js";
18import { lexicons } from "../utils/types/lexicons.js";
19import { verifyRecord } from "../utils/verify.js";
20
21export const RecordView = () => {
22 const location = useLocation();
23 const navigate = useNavigate();
24 const params = useParams();
25 const [openDelete, setOpenDelete] = createSignal(false);
26 const [notice, setNotice] = createSignal("");
27 const [externalLink, setExternalLink] = createSignal<
28 { label: string; link: string; icon?: string } | undefined
29 >();
30 const [lexiconUri, setLexiconUri] = createSignal<string>();
31 const [validRecord, setValidRecord] = createSignal<boolean | undefined>(undefined);
32 const [validSchema, setValidSchema] = createSignal<boolean | undefined>(undefined);
33 const did = params.repo;
34 let rpc: Client;
35
36 const fetchRecord = async () => {
37 setValidRecord(undefined);
38 setValidSchema(undefined);
39 setLexiconUri(undefined);
40 const pds = await resolvePDS(did);
41 rpc = new Client({ handler: new CredentialManager({ service: pds }) });
42 const res = await rpc.get("com.atproto.repo.getRecord", {
43 params: {
44 repo: did as ActorIdentifier,
45 collection: params.collection as `${string}.${string}.${string}`,
46 rkey: params.rkey,
47 },
48 });
49 if (!res.ok) {
50 setValidRecord(false);
51 setNotice(res.data.error);
52 throw new Error(res.data.error);
53 }
54 setPlaceholder(res.data.value);
55 setExternalLink(checkUri(res.data.uri, res.data.value));
56 resolveLexicon(params.collection as Nsid);
57 verify(res.data);
58
59 return res.data;
60 };
61
62 const [record, { refetch }] = createResource(fetchRecord);
63
64 const verify = async (record: {
65 uri: ResourceUri;
66 value: Record<string, unknown>;
67 cid?: string | undefined;
68 }) => {
69 try {
70 if (params.collection in lexicons) {
71 if (is(lexicons[params.collection], record.value)) setValidSchema(true);
72 else setValidSchema(false);
73 } else if (params.collection === "com.atproto.lexicon.schema") {
74 try {
75 lexiconDoc.parse(record.value, { mode: "passthrough" });
76 setValidSchema(true);
77 } catch (e) {
78 console.error(e);
79 setValidSchema(false);
80 }
81 }
82 const { errors } = await verifyRecord({
83 rpc: rpc,
84 uri: record.uri,
85 cid: record.cid!,
86 record: record.value,
87 didDoc: didDocCache[record.uri.split("/")[2]],
88 });
89
90 if (errors.length > 0) {
91 console.warn(errors);
92 setNotice(`Invalid record: ${errors.map((e) => e.message).join("\n")}`);
93 }
94 setValidRecord(errors.length === 0);
95 } catch (err) {
96 console.error(err);
97 setValidRecord(false);
98 }
99 };
100
101 const resolveLexicon = async (nsid: Nsid) => {
102 try {
103 const res = await resolveLexiconAuthority(nsid);
104 setLexiconUri(`at://${res}/com.atproto.lexicon.schema/${nsid}`);
105 } catch {}
106 };
107
108 const deleteRecord = async () => {
109 rpc = new Client({ handler: agent()! });
110 await rpc.post("com.atproto.repo.deleteRecord", {
111 input: {
112 repo: params.repo as ActorIdentifier,
113 collection: params.collection as `${string}.${string}.${string}`,
114 rkey: params.rkey,
115 },
116 });
117 setNotif({ show: true, icon: "lucide--trash-2", text: "Record deleted" });
118 navigate(`/at://${params.repo}/${params.collection}`);
119 };
120
121 const checkUri = (uri: string, record: any) => {
122 const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"]
123 if (uriParts.length != 5) return undefined;
124 if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined;
125 const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] };
126 const template = uriTemplates[parsedUri.collection];
127 if (!template) return undefined;
128 return template(parsedUri, record);
129 };
130
131 const RecordTab = (props: {
132 tab: "record" | "backlinks" | "info";
133 label: string;
134 error?: boolean;
135 }) => (
136 <div class="flex items-center gap-0.5">
137 <A
138 classList={{
139 "flex items-center gap-1 border-b-2": true,
140 "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600":
141 (!!location.hash && location.hash !== `#${props.tab}`) ||
142 (!location.hash && props.tab !== "record"),
143 }}
144 href={`/at://${did}/${params.collection}/${params.rkey}#${props.tab}`}
145 >
146 {props.label}
147 </A>
148 <Show when={props.error && (validRecord() === false || validSchema() === false)}>
149 <span class="iconify lucide--x text-red-500 dark:text-red-400"></span>
150 </Show>
151 </div>
152 );
153
154 return (
155 <Show when={record()} keyed>
156 <div class="flex w-full flex-col items-center">
157 <div class="dark:shadow-dark-700 dark:bg-dark-300 mb-3 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">
158 <div class="flex gap-3">
159 <RecordTab tab="record" label="Record" />
160 <RecordTab tab="backlinks" label="Backlinks" />
161 <RecordTab tab="info" label="Info" error />
162 </div>
163 <div class="flex gap-1">
164 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
165 <RecordEditor create={false} record={record()?.value} refetch={refetch} />
166 <Tooltip text="Delete">
167 <button
168 class="flex items-center rounded-sm p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
169 onclick={() => setOpenDelete(true)}
170 >
171 <span class="iconify lucide--trash-2"></span>
172 </button>
173 </Tooltip>
174 <Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
175 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0">
176 <h2 class="mb-2 font-semibold">Delete this record?</h2>
177 <div class="flex justify-end gap-2">
178 <Button onClick={() => setOpenDelete(false)}>Cancel</Button>
179 <Button
180 onClick={deleteRecord}
181 class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400"
182 >
183 Delete
184 </Button>
185 </div>
186 </div>
187 </Modal>
188 </Show>
189 <MenuProvider>
190 <DropdownMenu
191 icon="lucide--ellipsis-vertical"
192 buttonClass="rounded-sm p-1"
193 menuClass="top-8 p-2 text-sm"
194 >
195 <CopyMenu
196 copyContent={JSON.stringify(record()?.value, null, 2)}
197 label="Copy record"
198 icon="lucide--copy"
199 />
200 <Show when={record()?.cid}>
201 {(cid) => <CopyMenu copyContent={cid()} label="Copy CID" icon="lucide--copy" />}
202 </Show>
203 <Show when={externalLink()}>
204 {(externalLink) => (
205 <NavMenu
206 href={externalLink()?.link}
207 icon={`${externalLink().icon ?? "lucide--app-window"}`}
208 label={`Open on ${externalLink().label}`}
209 newTab
210 />
211 )}
212 </Show>
213 <NavMenu
214 href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`}
215 icon="lucide--external-link"
216 label="Record on PDS"
217 newTab
218 />
219 </DropdownMenu>
220 </MenuProvider>
221 </div>
222 </div>
223 <Show when={!location.hash || location.hash === "#record"}>
224 <div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-[48rem]">
225 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} />
226 </div>
227 </Show>
228 <Show when={location.hash === "#backlinks"}>
229 <ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}>
230 <Suspense
231 fallback={
232 <div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
233 }
234 >
235 <div class="w-full px-2">
236 <Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} />
237 </div>
238 </Suspense>
239 </ErrorBoundary>
240 </Show>
241 <Show when={location.hash === "#info"}>
242 <div class="flex w-full flex-col gap-2 px-2 text-sm">
243 <div>
244 <div class="flex items-center gap-1">
245 <span class="iconify lucide--at-sign"></span>
246 <p class="font-semibold">AT URI</p>
247 </div>
248 <div class="truncate text-xs">{record()?.uri}</div>
249 </div>
250 <Show when={record()?.cid}>
251 <div>
252 <div class="flex items-center gap-1">
253 <span class="iconify lucide--box"></span>
254 <p class="font-semibold">CID</p>
255 </div>
256 <div class="truncate text-left text-xs" dir="rtl">
257 {record()?.cid}
258 </div>
259 </div>
260 </Show>
261 <div>
262 <div class="flex items-center gap-1">
263 <span class="iconify lucide--lock-keyhole"></span>
264 <p class="font-semibold">Record verification</p>
265 <span
266 classList={{
267 "iconify lucide--check text-green-500 dark:text-green-400":
268 validRecord() === true,
269 "iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false,
270 "iconify lucide--loader-circle animate-spin": validRecord() === undefined,
271 }}
272 ></span>
273 </div>
274 <Show when={validRecord() === false}>
275 <div class="break-words">{notice()}</div>
276 </Show>
277 </div>
278 <Show when={validSchema() !== undefined}>
279 <div class="flex items-center gap-1">
280 <span class="iconify lucide--file-check"></span>
281 <p class="font-semibold">Schema validation</p>
282 <span
283 class={`iconify ${validSchema() ? "lucide--check text-green-500 dark:text-green-400" : "lucide--x text-red-500 dark:text-red-400"}`}
284 ></span>
285 </div>
286 </Show>
287 <Show when={lexiconUri()}>
288 <div>
289 <div class="flex items-center gap-1">
290 <span class="iconify lucide--scroll-text"></span>
291 <p class="font-semibold">Lexicon schema</p>
292 </div>
293 <div class="truncate text-xs">
294 <A
295 href={`/${lexiconUri()}`}
296 class="text-blue-400 hover:underline active:underline"
297 >
298 {lexiconUri()}
299 </A>
300 </div>
301 </div>
302 </Show>
303 </div>
304 </Show>
305 </div>
306 </Show>
307 );
308};