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