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