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