atmosphere explorer pds.ls
tool typescript atproto
at main 392 lines 12 kB view raw
1import { isCid, isDid, isNsid, isResourceUri, Nsid } from "@atcute/lexicons/syntax"; 2import { A, useNavigate, useParams } from "@solidjs/router"; 3import { 4 createContext, 5 createEffect, 6 createResource, 7 createSignal, 8 ErrorBoundary, 9 For, 10 on, 11 Show, 12 useContext, 13} from "solid-js"; 14import { resolveLexiconAuthority } from "../utils/api"; 15import { formatFileSize } from "../utils/format"; 16import { hideMedia } from "../views/settings"; 17import DidHoverCard from "./hover-card/did"; 18import RecordHoverCard from "./hover-card/record"; 19import { pds } from "./navbar"; 20import { addNotification, removeNotification } from "./notification"; 21import VideoPlayer from "./video-player"; 22 23interface JSONContext { 24 repo: string; 25 truncate?: boolean; 26 parentIsBlob?: boolean; 27 newTab?: boolean; 28 hideBlobs?: boolean; 29} 30 31const JSONCtx = createContext<JSONContext>(); 32const useJSONCtx = () => useContext(JSONCtx)!; 33 34interface AtBlob { 35 $type: string; 36 ref: { $link: string }; 37 mimeType: string; 38 size: number; 39} 40 41const isURL = 42 URL.canParse ?? 43 ((url, base) => { 44 try { 45 new URL(url, base); 46 return true; 47 } catch { 48 return false; 49 } 50 }); 51 52const JSONString = (props: { data: string; isType?: boolean; isLink?: boolean }) => { 53 const ctx = useJSONCtx(); 54 const navigate = useNavigate(); 55 const params = useParams(); 56 57 const handleClick = async (lex: string) => { 58 try { 59 const [nsid, anchor] = lex.split("#"); 60 const authority = await resolveLexiconAuthority(nsid as Nsid); 61 62 const hash = anchor ? `#schema:${anchor}` : "#schema"; 63 if (ctx.newTab) 64 window.open(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`, "_blank"); 65 else navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 66 } catch (err) { 67 console.error("Failed to resolve lexicon authority:", err); 68 const id = addNotification({ 69 message: "Could not resolve schema", 70 type: "error", 71 }); 72 setTimeout(() => removeNotification(id), 5000); 73 } 74 }; 75 76 const MAX_LENGTH = 200; 77 const isTruncated = () => ctx.truncate && props.data.length > MAX_LENGTH; 78 const displayData = () => (isTruncated() ? props.data.slice(0, MAX_LENGTH) : props.data); 79 const remainingChars = () => props.data.length - MAX_LENGTH; 80 81 return ( 82 <span> 83 " 84 <For each={displayData().split(/(\s)/)}> 85 {(part) => ( 86 <> 87 {isResourceUri(part) ? 88 <RecordHoverCard uri={part} newTab={ctx.newTab} /> 89 : isDid(part) ? 90 <DidHoverCard did={part} newTab={ctx.newTab} /> 91 : isNsid(part.split("#")[0]) && props.isType ? 92 <button 93 type="button" 94 onClick={() => handleClick(part)} 95 class="cursor-pointer text-blue-500 hover:underline active:underline dark:text-blue-400" 96 > 97 {part} 98 </button> 99 : isCid(part) && props.isLink && ctx.parentIsBlob && params.repo ? 100 <A 101 class="text-blue-500 hover:underline active:underline dark:text-blue-400" 102 rel="noopener" 103 target="_blank" 104 href={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${params.repo}&cid=${part}`} 105 > 106 {part} 107 </A> 108 : ( 109 isURL(part) && 110 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) && 111 part.split("\n").length === 1 112 ) ? 113 <a 114 class="underline hover:text-blue-500 dark:hover:text-blue-400" 115 href={part} 116 target="_blank" 117 rel="noopener" 118 > 119 {part} 120 </a> 121 : part} 122 </> 123 )} 124 </For> 125 <Show when={isTruncated()}> 126 <span>…</span> 127 </Show> 128 " 129 <Show when={isTruncated()}> 130 <span class="ml-1 text-neutral-500 dark:text-neutral-400"> 131 (+{remainingChars().toLocaleString()}) 132 </span> 133 </Show> 134 </span> 135 ); 136}; 137 138const JSONNumber = ({ data, isSize }: { data: number; isSize?: boolean }) => { 139 return ( 140 <span class="flex gap-1"> 141 {data} 142 <Show when={isSize}> 143 <span class="text-neutral-500 dark:text-neutral-400">({formatFileSize(data)})</span> 144 </Show> 145 </span> 146 ); 147}; 148 149const JSONBoolean = ({ data }: { data: boolean }) => { 150 return <span>{data ? "true" : "false"}</span>; 151}; 152 153const JSONNull = () => { 154 return <span>null</span>; 155}; 156 157const CollapsibleItem = (props: { 158 label: string | number; 159 value: JSONType; 160 maxWidth?: string; 161 isType?: boolean; 162 isLink?: boolean; 163 isSize?: boolean; 164 parentIsBlob?: boolean; 165}) => { 166 const ctx = useJSONCtx(); 167 const [show, setShow] = createSignal(true); 168 const isBlobContext = props.parentIsBlob ?? ctx.parentIsBlob; 169 170 return ( 171 <span 172 classList={{ 173 "group/indent flex gap-x-1 w-full": true, 174 "flex-col": props.value === Object(props.value), 175 }} 176 > 177 <button 178 class="group/clip relative flex size-fit shrink-0 items-center wrap-anywhere text-neutral-500 hover:text-neutral-700 active:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 dark:active:text-neutral-300" 179 classList={{ 180 "max-w-[40%] sm:max-w-[50%]": props.maxWidth !== undefined, 181 }} 182 onclick={() => setShow(!show())} 183 > 184 <span 185 classList={{ 186 "dark:bg-dark-500 absolute w-4 flex items-center -left-4 bg-neutral-100 text-sm": true, 187 "hidden group-hover/clip:flex": show(), 188 }} 189 > 190 {show() ? 191 <span class="iconify lucide--chevron-down"></span> 192 : <span class="iconify lucide--chevron-right"></span>} 193 </span> 194 {props.label}: 195 </button> 196 <span 197 classList={{ 198 "self-center": props.value !== Object(props.value), 199 "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": 200 props.value === Object(props.value), 201 "invisible h-0 overflow-hidden": !show(), 202 }} 203 > 204 <JSONCtx.Provider value={{ ...ctx, parentIsBlob: isBlobContext }}> 205 <JSONValueInner 206 data={props.value} 207 isType={props.isType} 208 isLink={props.isLink} 209 isSize={props.isSize} 210 /> 211 </JSONCtx.Provider> 212 </span> 213 </span> 214 ); 215}; 216 217const JSONObject = (props: { data: { [x: string]: JSONType } }) => { 218 const ctx = useJSONCtx(); 219 const params = useParams(); 220 const [hide, setHide] = createSignal( 221 localStorage.hideMedia === "true" || params.rkey === undefined, 222 ); 223 const [mediaLoaded, setMediaLoaded] = createSignal(false); 224 225 createEffect(() => { 226 if (hideMedia()) setHide(hideMedia()); 227 }); 228 229 createEffect( 230 on( 231 hide, 232 (value) => { 233 if (value === false) setMediaLoaded(false); 234 }, 235 { defer: true }, 236 ), 237 ); 238 239 const isBlob = props.data.$type === "blob"; 240 const isBlobContext = isBlob || ctx.parentIsBlob; 241 242 const rawObj = ( 243 <For each={Object.entries(props.data)}> 244 {([key, value]) => ( 245 <CollapsibleItem 246 label={key} 247 value={value} 248 maxWidth="set" 249 isType={key === "$type"} 250 isLink={key === "$link"} 251 isSize={key === "size" && isBlob} 252 parentIsBlob={isBlobContext} 253 /> 254 )} 255 </For> 256 ); 257 258 const blob: AtBlob = props.data as any; 259 const canShowMedia = () => 260 pds() && 261 !ctx.hideBlobs && 262 (blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4"); 263 264 const MediaDisplay = () => { 265 const [imageUrl] = createResource( 266 () => (blob.mimeType.startsWith("image/") ? blob.ref.$link : null), 267 async (cid) => { 268 const url = `https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`; 269 270 await new Promise<void>((resolve) => { 271 const img = new Image(); 272 img.src = url; 273 img.onload = () => resolve(); 274 img.onerror = () => resolve(); 275 }); 276 277 return url; 278 }, 279 ); 280 281 return ( 282 <div> 283 <span class="group/media relative flex w-fit"> 284 <Show when={!hide()}> 285 <Show when={blob.mimeType.startsWith("image/")}> 286 <Show 287 when={!imageUrl.loading && imageUrl()} 288 fallback={ 289 <div class="flex h-48 w-48 items-center justify-center rounded bg-neutral-200 dark:bg-neutral-800"> 290 <span class="iconify lucide--loader-circle animate-spin text-xl text-neutral-400 dark:text-neutral-500"></span> 291 </div> 292 } 293 > 294 <img 295 class="h-auto max-h-48 max-w-64 object-contain" 296 src={imageUrl()} 297 onLoad={() => setMediaLoaded(true)} 298 /> 299 </Show> 300 </Show> 301 <Show when={blob.mimeType === "video/mp4"}> 302 <ErrorBoundary fallback={() => <span>Failed to load video</span>}> 303 <VideoPlayer 304 did={ctx.repo} 305 cid={blob.ref.$link} 306 onLoad={() => setMediaLoaded(true)} 307 /> 308 </ErrorBoundary> 309 </Show> 310 <Show when={mediaLoaded()}> 311 <button 312 onclick={() => setHide(true)} 313 class="absolute top-1 right-1 flex items-center rounded-lg bg-neutral-700/70 p-1.5 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/media:opacity-100 hover:bg-neutral-700 active:bg-neutral-800 dark:bg-neutral-100/70 dark:text-neutral-900 dark:hover:bg-neutral-100 dark:active:bg-neutral-200" 314 > 315 <span class="iconify lucide--eye-off text-base"></span> 316 </button> 317 </Show> 318 </Show> 319 <Show when={hide()}> 320 <button 321 onclick={() => setHide(false)} 322 class="flex items-center gap-1 rounded-md bg-neutral-200 px-2 py-1.5 text-sm transition-colors hover:bg-neutral-300 active:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 323 > 324 <span class="iconify lucide--image"></span> 325 <span class="font-sans">Show media</span> 326 </button> 327 </Show> 328 </span> 329 </div> 330 ); 331 }; 332 333 if (blob.$type === "blob") { 334 return ( 335 <> 336 <Show when={canShowMedia()}> 337 <MediaDisplay /> 338 </Show> 339 {rawObj} 340 </> 341 ); 342 } 343 344 return rawObj; 345}; 346 347const JSONArray = (props: { data: JSONType[] }) => { 348 return ( 349 <For each={props.data}> 350 {(value, index) => <CollapsibleItem label={`#${index()}`} value={value} />} 351 </For> 352 ); 353}; 354 355const JSONValueInner = (props: { 356 data: JSONType; 357 isType?: boolean; 358 isLink?: boolean; 359 isSize?: boolean; 360}) => { 361 const data = props.data; 362 if (typeof data === "string") 363 return <JSONString data={data} isType={props.isType} isLink={props.isLink} />; 364 if (typeof data === "number") return <JSONNumber data={data} isSize={props.isSize} />; 365 if (typeof data === "boolean") return <JSONBoolean data={data} />; 366 if (data === null) return <JSONNull />; 367 if (Array.isArray(data)) return <JSONArray data={data} />; 368 return <JSONObject data={data} />; 369}; 370 371export const JSONValue = (props: { 372 data: JSONType; 373 repo: string; 374 truncate?: boolean; 375 newTab?: boolean; 376 hideBlobs?: boolean; 377}) => { 378 return ( 379 <JSONCtx.Provider 380 value={{ 381 repo: props.repo, 382 truncate: props.truncate, 383 newTab: props.newTab, 384 hideBlobs: props.hideBlobs, 385 }} 386 > 387 <JSONValueInner data={props.data} /> 388 </JSONCtx.Provider> 389 ); 390}; 391 392export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];