atproto explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 308 lines 13 kB view raw
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};