atmosphere explorer
at main 424 lines 14 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 <span class="text-neutral-500 dark:text-neutral-400">"</span> 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 <span class="text-neutral-500 dark:text-neutral-400">"</span> 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 CollapsibleItem = (props: { 150 label: string | number; 151 value: JSONType; 152 maxWidth?: string; 153 isType?: boolean; 154 isLink?: boolean; 155 isSize?: boolean; 156 isIndex?: boolean; 157 parentIsBlob?: boolean; 158}) => { 159 const ctx = useJSONCtx(); 160 const [show, setShow] = createSignal(true); 161 const isBlobContext = props.parentIsBlob ?? ctx.parentIsBlob; 162 163 const isObject = () => props.value === Object(props.value); 164 const isEmpty = () => 165 Array.isArray(props.value) ? 166 (props.value as JSONType[]).length === 0 167 : Object.keys(props.value as object).length === 0; 168 const isCollapsible = () => (isObject() && !isEmpty()) || typeof props.value === "string"; 169 const summary = () => { 170 if (typeof props.value === "string") { 171 const len = props.value.length; 172 return `${len.toLocaleString()} ${len === 1 ? "char" : "chars"}`; 173 } 174 if (Array.isArray(props.value)) { 175 const len = (props.value as JSONType[]).length; 176 return `[ ${len} ${len === 1 ? "item" : "items"} ]`; 177 } 178 const len = Object.keys(props.value as object).length; 179 return `{ ${len} ${len === 1 ? "key" : "keys"} }`; 180 }; 181 182 return ( 183 <span 184 classList={{ 185 "group/indent flex gap-x-1 w-full": true, 186 "flex-col": isObject() && !isEmpty(), 187 }} 188 > 189 <button 190 class="group/clip relative flex size-fit shrink-0 items-center gap-x-1 wrap-anywhere" 191 classList={{ 192 "max-w-[40%] sm:max-w-[50%]": props.maxWidth !== undefined && show(), 193 "text-indigo-500 hover:text-indigo-700 active:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 dark:active:text-indigo-200": 194 !props.isIndex, 195 "text-violet-500 hover:text-violet-700 active:text-violet-800 dark:text-violet-400 dark:hover:text-violet-300 dark:active:text-violet-200": 196 props.isIndex, 197 }} 198 onclick={() => isCollapsible() && setShow(!show())} 199 > 200 <Show when={isCollapsible()}> 201 <span 202 classList={{ 203 "dark:bg-dark-500 absolute w-4 text-neutral-500 dark:text-neutral-400 flex items-center -left-4 bg-neutral-100 text-sm": true, 204 "hidden group-hover/clip:flex": show(), 205 }} 206 > 207 {show() ? 208 <span class="iconify lucide--chevron-down"></span> 209 : <span class="iconify lucide--chevron-right"></span>} 210 </span> 211 </Show> 212 <span> 213 {props.label} 214 <span class="text-neutral-500 dark:text-neutral-400">:</span> 215 </span> 216 <Show when={!show() && summary()}> 217 <span class="absolute left-full ml-1 whitespace-nowrap text-neutral-400 dark:text-neutral-500"> 218 {summary()} 219 </span> 220 </Show> 221 </button> 222 <span 223 classList={{ 224 "self-center": !isObject() || isEmpty(), 225 "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-400": 226 isObject() && !isEmpty(), 227 "invisible h-0 overflow-hidden": !show(), 228 }} 229 > 230 <JSONCtx.Provider value={{ ...ctx, parentIsBlob: isBlobContext }}> 231 <JSONValueInner 232 data={props.value} 233 isType={props.isType} 234 isLink={props.isLink} 235 isSize={props.isSize} 236 /> 237 </JSONCtx.Provider> 238 </span> 239 </span> 240 ); 241}; 242 243const JSONObject = (props: { data: { [x: string]: JSONType } }) => { 244 const ctx = useJSONCtx(); 245 const params = useParams(); 246 const [hide, setHide] = createSignal( 247 localStorage.hideMedia === "true" || params.rkey === undefined, 248 ); 249 const [mediaLoaded, setMediaLoaded] = createSignal(false); 250 251 createEffect(() => { 252 if (hideMedia()) setHide(hideMedia()); 253 }); 254 255 createEffect( 256 on( 257 hide, 258 (value) => { 259 if (value === false) setMediaLoaded(false); 260 }, 261 { defer: true }, 262 ), 263 ); 264 265 const isBlob = props.data.$type === "blob"; 266 const isBlobContext = isBlob || ctx.parentIsBlob; 267 268 const rawObj = ( 269 <For each={Object.entries(props.data)}> 270 {([key, value]) => ( 271 <CollapsibleItem 272 label={key} 273 value={value} 274 maxWidth="set" 275 isType={key === "$type"} 276 isLink={key === "$link"} 277 isSize={key === "size" && isBlob} 278 parentIsBlob={isBlobContext} 279 /> 280 )} 281 </For> 282 ); 283 284 const blob: AtBlob = props.data as any; 285 const canShowMedia = () => 286 pds() && 287 !ctx.hideBlobs && 288 (blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4"); 289 290 const MediaDisplay = () => { 291 const [imageUrl] = createResource( 292 () => (blob.mimeType.startsWith("image/") ? blob.ref.$link : null), 293 async (cid) => { 294 const url = `https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`; 295 296 await new Promise<void>((resolve) => { 297 const img = new Image(); 298 img.src = url; 299 img.onload = () => resolve(); 300 img.onerror = () => resolve(); 301 }); 302 303 return url; 304 }, 305 ); 306 307 return ( 308 <div> 309 <span class="group/media relative flex w-fit"> 310 <Show when={!hide()}> 311 <Show when={blob.mimeType.startsWith("image/")}> 312 <Show 313 when={!imageUrl.loading && imageUrl()} 314 fallback={ 315 <div class="flex h-48 w-48 items-center justify-center rounded bg-neutral-200 dark:bg-neutral-800"> 316 <span class="iconify lucide--loader-circle animate-spin text-xl text-neutral-400 dark:text-neutral-500"></span> 317 </div> 318 } 319 > 320 <img 321 class="h-auto max-h-48 max-w-64 object-contain" 322 src={imageUrl()} 323 onLoad={() => setMediaLoaded(true)} 324 /> 325 </Show> 326 </Show> 327 <Show when={blob.mimeType === "video/mp4"}> 328 <ErrorBoundary fallback={() => <span>Failed to load video</span>}> 329 <VideoPlayer 330 did={ctx.repo} 331 cid={blob.ref.$link} 332 onLoad={() => setMediaLoaded(true)} 333 /> 334 </ErrorBoundary> 335 </Show> 336 <Show when={mediaLoaded()}> 337 <button 338 onclick={() => setHide(true)} 339 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" 340 > 341 <span class="iconify lucide--eye-off text-base"></span> 342 </button> 343 </Show> 344 </Show> 345 <Show when={hide()}> 346 <button 347 onclick={() => setHide(false)} 348 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" 349 > 350 <span class="iconify lucide--image"></span> 351 <span class="font-sans">Show media</span> 352 </button> 353 </Show> 354 </span> 355 </div> 356 ); 357 }; 358 359 if (Object.keys(props.data).length === 0) 360 return <span class="text-neutral-400 dark:text-neutral-500">{"{ }"}</span>; 361 362 if (blob.$type === "blob") { 363 return ( 364 <> 365 <Show when={canShowMedia()}> 366 <MediaDisplay /> 367 </Show> 368 {rawObj} 369 </> 370 ); 371 } 372 373 return rawObj; 374}; 375 376const JSONArray = (props: { data: JSONType[] }) => { 377 if (props.data.length === 0) 378 return <span class="text-neutral-400 dark:text-neutral-500">[ ]</span>; 379 return ( 380 <For each={props.data}> 381 {(value, index) => <CollapsibleItem label={`#${index()}`} value={value} isIndex />} 382 </For> 383 ); 384}; 385 386const JSONValueInner = (props: { 387 data: JSONType; 388 isType?: boolean; 389 isLink?: boolean; 390 isSize?: boolean; 391}) => { 392 const data = props.data; 393 if (typeof data === "string") 394 return <JSONString data={data} isType={props.isType} isLink={props.isLink} />; 395 if (typeof data === "number") return <JSONNumber data={data} isSize={props.isSize} />; 396 if (typeof data === "boolean") 397 return <span class="text-amber-500 dark:text-amber-400">{String(data)}</span>; 398 if (data === null) return <span class="text-neutral-400 dark:text-neutral-500">null</span>; 399 if (Array.isArray(data)) return <JSONArray data={data} />; 400 return <JSONObject data={data} />; 401}; 402 403export const JSONValue = (props: { 404 data: JSONType; 405 repo: string; 406 truncate?: boolean; 407 newTab?: boolean; 408 hideBlobs?: boolean; 409}) => { 410 return ( 411 <JSONCtx.Provider 412 value={{ 413 repo: props.repo, 414 truncate: props.truncate, 415 newTab: props.newTab, 416 hideBlobs: props.hideBlobs, 417 }} 418 > 419 <JSONValueInner data={props.data} /> 420 </JSONCtx.Provider> 421 ); 422}; 423 424export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];