forked from
pds.ls/pdsls
this repo has no description
1import { CredentialManager, Client } from "@atcute/client";
2
3import { useNavigate, useParams } from "@solidjs/router";
4import { createSignal, onMount, Show } from "solid-js";
5
6import { Backlinks } from "../components/backlinks.jsx";
7import { JSONValue } from "../components/json.jsx";
8import { agent } from "../components/login.jsx";
9import { pds, setCID, setValidRecord, setValidSchema, validRecord } from "../components/navbar.jsx";
10
11import { didDocCache, getAllBacklinks, LinkData, resolvePDS } from "../utils/api.js";
12import { AtUri, uriTemplates } from "../utils/templates.js";
13import { verifyRecord } from "../utils/verify.js";
14import { ActorIdentifier, InferXRPCBodyOutput, is } from "@atcute/lexicons";
15import { lexiconDoc } from "@atcute/lexicon-doc";
16import { ComAtprotoRepoGetRecord } from "@atcute/atproto";
17import { lexicons } from "../utils/types/lexicons.js";
18import { RecordEditor } from "../components/create.jsx";
19import { addToClipboard } from "../utils/copy.js";
20import Tooltip from "../components/tooltip.jsx";
21import { Modal } from "../components/modal.jsx";
22
23export const RecordView = () => {
24 const navigate = useNavigate();
25 const params = useParams();
26 const [record, setRecord] =
27 createSignal<InferXRPCBodyOutput<ComAtprotoRepoGetRecord.mainSchema["output"]>>();
28 const [backlinks, setBacklinks] = createSignal<{ links: LinkData; target: string }>();
29 const [openDelete, setOpenDelete] = createSignal(false);
30 const [notice, setNotice] = createSignal("");
31 const [showBacklinks, setShowBacklinks] = createSignal(false);
32 const [externalLink, setExternalLink] = createSignal<
33 { label: string; link: string; icon?: string } | undefined
34 >();
35 const did = params.repo;
36 let rpc: Client;
37
38 onMount(async () => {
39 setCID(undefined);
40 setValidRecord(undefined);
41 setValidSchema(undefined);
42 const pds = await resolvePDS(did);
43 rpc = new Client({ handler: new CredentialManager({ service: pds }) });
44 const res = await rpc.get("com.atproto.repo.getRecord", {
45 params: {
46 repo: did as ActorIdentifier,
47 collection: params.collection as `${string}.${string}.${string}`,
48 rkey: params.rkey,
49 },
50 });
51 if (!res.ok) {
52 setValidRecord(false);
53 setNotice(res.data.error);
54 throw new Error(res.data.error);
55 }
56 setRecord(res.data);
57 setCID(res.data.cid);
58 setExternalLink(checkUri(res.data.uri));
59
60 try {
61 if (params.collection in lexicons) {
62 if (is(lexicons[params.collection], res.data.value)) setValidSchema(true);
63 else setValidSchema(false);
64 } else if (params.collection === "com.atproto.lexicon.schema") {
65 try {
66 lexiconDoc.parse(res.data.value, { mode: "passthrough" });
67 setValidSchema(true);
68 } catch (e) {
69 console.error(e);
70 setValidSchema(false);
71 }
72 }
73 const { errors } = await verifyRecord({
74 rpc: rpc,
75 uri: res.data.uri,
76 cid: res.data.cid!,
77 record: res.data.value,
78 didDoc: didDocCache[res.data.uri.split("/")[2]],
79 });
80
81 if (errors.length > 0) {
82 console.warn(errors);
83 setNotice(`Invalid record: ${errors.map((e) => e.message).join("\n")}`);
84 }
85 setValidRecord(errors.length === 0);
86 } catch (err) {
87 console.error(err);
88 setValidRecord(false);
89 }
90 if (localStorage.backlinks === "true") {
91 try {
92 const backlinkTarget = `at://${did}/${params.collection}/${params.rkey}`;
93 const backlinks = await getAllBacklinks(backlinkTarget);
94 setBacklinks({ links: backlinks.links, target: backlinkTarget });
95 } catch (e) {
96 console.error(e);
97 }
98 }
99 });
100
101 const deleteRecord = async () => {
102 rpc = new Client({ handler: agent()! });
103 await rpc.post("com.atproto.repo.deleteRecord", {
104 input: {
105 repo: params.repo as ActorIdentifier,
106 collection: params.collection as `${string}.${string}.${string}`,
107 rkey: params.rkey,
108 },
109 });
110 navigate(`/at://${params.repo}/${params.collection}`);
111 };
112
113 const checkUri = (uri: string) => {
114 const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"]
115 if (uriParts.length != 5) return undefined;
116 if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined;
117 const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] };
118 const template = uriTemplates[parsedUri.collection];
119 if (!template) return undefined;
120 return template(parsedUri);
121 };
122
123 return (
124 <div class="flex w-full flex-col items-center">
125 <Show when={record() === undefined && validRecord() !== false}>
126 <div class="i-lucide-loader-circle mt-3 animate-spin text-xl" />
127 </Show>
128 <Show when={validRecord() === false}>
129 <div class="mt-3 break-words text-red-500 dark:text-red-400">{notice()}</div>
130 </Show>
131 <Show when={record()}>
132 <div class="dark:shadow-dark-900/80 dark:bg-dark-300 my-3 flex gap-3 rounded-full bg-white px-2.5 py-2 shadow-sm">
133 <Tooltip text="Copy record">
134 <button onclick={() => addToClipboard(JSON.stringify(record()?.value, null, 2))}>
135 <div class="i-lucide-copy text-xl" />
136 </button>
137 </Tooltip>
138 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
139 <RecordEditor create={false} record={record()?.value} />
140 <div class="relative flex">
141 <Tooltip text="Delete">
142 <button onclick={() => setOpenDelete(true)}>
143 <div class="i-lucide-trash-2 text-xl" />
144 </button>
145 </Tooltip>
146 <Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
147 <div class="starting:opacity-0 dark:bg-dark-800/70 border-0.5 dark:shadow-dark-900/80 backdrop-blur-xs left-50% top-70 absolute -translate-x-1/2 rounded-md border-neutral-300 bg-zinc-200/70 p-4 text-slate-900 shadow-md transition-opacity duration-300 dark:border-neutral-700 dark:text-slate-100">
148 <h2 class="mb-2 font-bold">Delete this record?</h2>
149 <div class="flex justify-end gap-2">
150 <button
151 type="button"
152 onclick={() => setOpenDelete(false)}
153 class="dark:hover:bg-dark-100 dark:bg-dark-300 dark:shadow-dark-900/80 rounded-lg bg-white px-2 py-1.5 text-sm font-bold shadow-sm hover:bg-zinc-100"
154 >
155 Cancel
156 </button>
157 <button
158 type="button"
159 onclick={deleteRecord}
160 class="dark:shadow-dark-900/80 rounded-lg bg-red-500 px-2 py-1.5 text-sm font-bold text-slate-100 shadow-sm hover:bg-red-400"
161 >
162 Delete
163 </button>
164 </div>
165 </div>
166 </Modal>
167 </div>
168 </Show>
169 <Show when={externalLink()}>
170 {(externalLink) => (
171 <Tooltip text={`Open on ${externalLink().label}`}>
172 <a target="_blank" href={externalLink()?.link}>
173 <div class={`${externalLink().icon ?? "i-lucide-app-window"} text-xl`} />
174 </a>
175 </Tooltip>
176 )}
177 </Show>
178 <Tooltip text="Record on PDS">
179 <a
180 href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`}
181 target="_blank"
182 >
183 <div class="i-lucide-external-link text-xl" />
184 </a>
185 </Tooltip>
186 <Show when={backlinks()}>
187 <Tooltip text={showBacklinks() ? "Show record" : "Show backlinks"}>
188 <button onclick={() => setShowBacklinks(!showBacklinks())}>
189 <div
190 class={`${showBacklinks() ? "i-lucide-file-json" : "i-lucide-send-to-back"} text-xl`}
191 />
192 </button>
193 </Tooltip>
194 </Show>
195 </div>
196 <Show when={!showBacklinks()}>
197 <div class="break-anywhere w-full whitespace-pre-wrap font-mono text-xs sm:text-sm">
198 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} />
199 </div>
200 </Show>
201 <Show when={showBacklinks()}>
202 <Show when={backlinks()}>
203 {(backlinks) => <Backlinks links={backlinks().links} target={backlinks().target} />}
204 </Show>
205 </Show>
206 </Show>
207 </div>
208 );
209};