atproto explorer pdsls.dev
atproto tool
1import { isCid, isDid, isNsid, Nsid } from "@atcute/lexicons/syntax"; 2import { A, useNavigate, useParams } from "@solidjs/router"; 3import { createEffect, createSignal, ErrorBoundary, For, on, Show } from "solid-js"; 4import { resolveLexiconAuthority } from "../utils/api"; 5import { ATURI_RE } from "../utils/types/at-uri"; 6import { hideMedia } from "../views/settings"; 7import { pds } from "./navbar"; 8import { addNotification, removeNotification } from "./notification"; 9import VideoPlayer from "./video-player"; 10 11interface AtBlob { 12 $type: string; 13 ref: { $link: string }; 14 mimeType: string; 15} 16 17const JSONString = (props: { 18 data: string; 19 isType?: boolean; 20 isLink?: boolean; 21 parentIsBlob?: boolean; 22}) => { 23 const navigate = useNavigate(); 24 const params = useParams(); 25 26 const isURL = 27 URL.canParse ?? 28 ((url, base) => { 29 try { 30 new URL(url, base); 31 return true; 32 } catch { 33 return false; 34 } 35 }); 36 37 const handleClick = async (lex: string) => { 38 try { 39 const [nsid, anchor] = lex.split("#"); 40 const authority = await resolveLexiconAuthority(nsid as Nsid); 41 42 const hash = anchor ? `#schema:${anchor}` : "#schema"; 43 navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 44 } catch (err) { 45 console.error("Failed to resolve lexicon authority:", err); 46 const id = addNotification({ 47 message: "Could not resolve schema", 48 type: "error", 49 }); 50 setTimeout(() => removeNotification(id), 5000); 51 } 52 }; 53 54 return ( 55 <span> 56 " 57 <For each={props.data.split(/(\s)/)}> 58 {(part) => ( 59 <> 60 {ATURI_RE.test(part) ? 61 <A class="text-blue-400 hover:underline active:underline" href={`/${part}`}> 62 {part} 63 </A> 64 : isDid(part) ? 65 <A class="text-blue-400 hover:underline active:underline" href={`/at://${part}`}> 66 {part} 67 </A> 68 : isNsid(part.split("#")[0]) && props.isType ? 69 <button 70 type="button" 71 onClick={() => handleClick(part)} 72 class="cursor-pointer text-blue-400 hover:underline active:underline" 73 > 74 {part} 75 </button> 76 : isCid(part) && props.isLink && props.parentIsBlob && params.repo ? 77 <A 78 class="text-blue-400 hover:underline active:underline" 79 rel="noopener" 80 target="_blank" 81 href={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${params.repo}&cid=${part}`} 82 > 83 {part} 84 </A> 85 : ( 86 isURL(part) && 87 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) && 88 part.split("\n").length === 1 89 ) ? 90 <a class="underline hover:text-blue-400" href={part} target="_blank" rel="noopener"> 91 {part} 92 </a> 93 : part} 94 </> 95 )} 96 </For> 97 " 98 </span> 99 ); 100}; 101 102const JSONNumber = ({ data }: { data: number }) => { 103 return <span>{data}</span>; 104}; 105 106const JSONBoolean = ({ data }: { data: boolean }) => { 107 return <span>{data ? "true" : "false"}</span>; 108}; 109 110const JSONNull = () => { 111 return <span>null</span>; 112}; 113 114const JSONObject = (props: { 115 data: { [x: string]: JSONType }; 116 repo: string; 117 parentIsBlob?: boolean; 118}) => { 119 const params = useParams(); 120 const [hide, setHide] = createSignal( 121 localStorage.hideMedia === "true" || params.rkey === undefined, 122 ); 123 const [mediaLoaded, setMediaLoaded] = createSignal(false); 124 125 createEffect(() => { 126 if (hideMedia()) setHide(hideMedia()); 127 }); 128 129 createEffect( 130 on( 131 hide, 132 (value) => { 133 if (value === false) setMediaLoaded(false); 134 }, 135 { defer: true }, 136 ), 137 ); 138 139 const isBlob = props.data.$type === "blob"; 140 const isBlobContext = isBlob || props.parentIsBlob; 141 142 const Obj = ({ key, value }: { key: string; value: JSONType }) => { 143 const [show, setShow] = createSignal(true); 144 145 return ( 146 <span 147 classList={{ 148 "group/indent flex gap-x-1 w-full": true, 149 "flex-col": value === Object(value), 150 }} 151 > 152 <button 153 class="group/clip relative flex size-fit max-w-[40%] shrink-0 items-center wrap-anywhere text-neutral-500 hover:text-neutral-700 active:text-neutral-700 sm:max-w-[50%] dark:text-neutral-400 dark:hover:text-neutral-300 dark:active:text-neutral-300" 154 onclick={() => setShow(!show())} 155 > 156 <span 157 classList={{ 158 "dark:bg-dark-500 absolute w-5 flex items-center -left-5 bg-neutral-100 text-sm": true, 159 "hidden group-hover/clip:flex": show(), 160 }} 161 > 162 {show() ? 163 <span class="iconify lucide--chevron-down"></span> 164 : <span class="iconify lucide--chevron-right"></span>} 165 </span> 166 {key}: 167 </button> 168 <span 169 classList={{ 170 "self-center": value !== Object(value), 171 "pl-[calc(2ch-0.5px)] border-l-[0.5px] border-neutral-500/50 dark:border-neutral-400/50 has-hover:group-hover/indent:border-neutral-700 transition-colors dark:has-hover:group-hover/indent:border-neutral-300": 172 value === Object(value), 173 "invisible h-0": !show(), 174 }} 175 > 176 <JSONValue 177 data={value} 178 repo={props.repo} 179 isType={key === "$type"} 180 isLink={key === "$link"} 181 parentIsBlob={isBlobContext} 182 /> 183 </span> 184 </span> 185 ); 186 }; 187 188 const rawObj = ( 189 <For each={Object.entries(props.data)}>{([key, value]) => <Obj key={key} value={value} />}</For> 190 ); 191 192 const blob: AtBlob = props.data as any; 193 194 if (blob.$type === "blob") { 195 return ( 196 <> 197 <Show when={pds() && params.rkey}> 198 <Show when={blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4"}> 199 <span class="group/media relative flex w-fit"> 200 <Show when={!hide()}> 201 <Show when={blob.mimeType.startsWith("image/")}> 202 <img 203 class="h-auto max-h-64 max-w-[16rem] object-contain" 204 src={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${props.repo}&cid=${blob.ref.$link}`} 205 onLoad={() => setMediaLoaded(true)} 206 /> 207 </Show> 208 <Show when={blob.mimeType === "video/mp4"}> 209 <ErrorBoundary fallback={() => <span>Failed to load video</span>}> 210 <VideoPlayer 211 did={props.repo} 212 cid={blob.ref.$link} 213 onLoad={() => setMediaLoaded(true)} 214 /> 215 </ErrorBoundary> 216 </Show> 217 <Show when={mediaLoaded()}> 218 <button 219 onclick={() => setHide(true)} 220 class="absolute top-1 right-1 flex items-center rounded-lg bg-neutral-900/70 p-1.5 text-white opacity-100 backdrop-blur-sm transition-opacity hover:bg-neutral-900/80 active:bg-neutral-900/90 sm:opacity-0 sm:group-hover/media:opacity-100 dark:bg-neutral-100/70 dark:text-neutral-900 dark:hover:bg-neutral-100/80 dark:active:bg-neutral-100/90" 221 > 222 <span class="iconify lucide--eye-off text-base"></span> 223 </button> 224 </Show> 225 </Show> 226 <Show when={hide()}> 227 <button 228 onclick={() => setHide(false)} 229 class="flex items-center rounded-lg bg-neutral-200 p-1.5 transition-colors hover:bg-neutral-300 active:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 230 > 231 <span class="iconify lucide--eye text-base"></span> 232 </button> 233 </Show> 234 </span> 235 </Show> 236 </Show> 237 {rawObj} 238 </> 239 ); 240 } 241 242 return rawObj; 243}; 244 245const JSONArray = (props: { data: JSONType[]; repo: string; parentIsBlob?: boolean }) => { 246 return ( 247 <For each={props.data}> 248 {(value, index) => ( 249 <span 250 classList={{ 251 "flex before:content-['-']": true, 252 "mb-2": value === Object(value) && index() !== props.data.length - 1, 253 }} 254 > 255 <span class="ml-[1ch] w-full"> 256 <JSONValue data={value} repo={props.repo} parentIsBlob={props.parentIsBlob} /> 257 </span> 258 </span> 259 )} 260 </For> 261 ); 262}; 263 264export const JSONValue = (props: { 265 data: JSONType; 266 repo: string; 267 isType?: boolean; 268 isLink?: boolean; 269 parentIsBlob?: boolean; 270}) => { 271 const data = props.data; 272 if (typeof data === "string") 273 return ( 274 <JSONString 275 data={data} 276 isType={props.isType} 277 isLink={props.isLink} 278 parentIsBlob={props.parentIsBlob} 279 /> 280 ); 281 if (typeof data === "number") return <JSONNumber data={data} />; 282 if (typeof data === "boolean") return <JSONBoolean data={data} />; 283 if (data === null) return <JSONNull />; 284 if (Array.isArray(data)) 285 return <JSONArray data={data} repo={props.repo} parentIsBlob={props.parentIsBlob} />; 286 return <JSONObject data={data} repo={props.repo} parentIsBlob={props.parentIsBlob} />; 287}; 288 289export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];