forked from
pds.ls/pdsls
atmosphere explorer
1import { isCid, isDid, isNsid, isResourceUri, Nsid } from "@atcute/lexicons/syntax";
2import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
3import {
4 createContext,
5 createEffect,
6 createResource,
7 createSignal,
8 ErrorBoundary,
9 For,
10 onCleanup,
11 Show,
12 useContext,
13} from "solid-js";
14import { Portal } from "solid-js/web";
15import { resolveLexiconAuthority } from "../lib/api";
16import { formatFileSize } from "../utils/format";
17import { hideMedia } from "../views/settings";
18import DidHoverCard from "./hover-card/did";
19import RecordHoverCard from "./hover-card/record";
20import { addNotification, removeNotification } from "./notification";
21import VideoPlayer from "./video-player";
22
23interface JSONContext {
24 repo: string;
25 pds?: string;
26 truncate?: boolean;
27 parentIsBlob?: boolean;
28 newTab?: boolean;
29 hideBlobs?: boolean;
30 keyLinks?: boolean;
31 path?: string;
32}
33
34const JSONCtx = createContext<JSONContext>();
35const useJSONCtx = () => useContext(JSONCtx)!;
36
37interface AtBlob {
38 $type: string;
39 ref: { $link: string };
40 mimeType: string;
41 size: number;
42}
43
44const isURL =
45 URL.canParse ??
46 ((url, base) => {
47 try {
48 new URL(url, base);
49 return true;
50 } catch {
51 return false;
52 }
53 });
54
55const JSONString = (props: { data: string; isType?: boolean; isLink?: boolean }) => {
56 const ctx = useJSONCtx();
57 const navigate = useNavigate();
58 const params = useParams();
59
60 const handleClick = async (lex: string) => {
61 try {
62 const [nsid, anchor] = lex.split("#");
63 const authority = await resolveLexiconAuthority(nsid as Nsid);
64
65 const hash = anchor ? `#schema:${anchor}` : "#schema";
66 if (ctx.newTab)
67 window.open(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`, "_blank");
68 else navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`);
69 } catch (err) {
70 console.error("Failed to resolve lexicon authority:", err);
71 const id = addNotification({
72 message: "Could not resolve schema",
73 type: "error",
74 });
75 setTimeout(() => removeNotification(id), 5000);
76 }
77 };
78
79 const MAX_LENGTH = 200;
80 const isTruncated = () => ctx.truncate && props.data.length > MAX_LENGTH;
81 const displayData = () => (isTruncated() ? props.data.slice(0, MAX_LENGTH) : props.data);
82 const remainingChars = () => props.data.length - MAX_LENGTH;
83
84 return (
85 <span>
86 <span class="text-neutral-500 dark:text-neutral-400">"</span>
87 <For each={displayData().split(/(\s)/)}>
88 {(part) => (
89 <>
90 {isResourceUri(part) ?
91 <RecordHoverCard uri={part} newTab={ctx.newTab} />
92 : isDid(part) ?
93 <DidHoverCard did={part} newTab={ctx.newTab} />
94 : isNsid(part.split("#")[0]) && props.isType ?
95 <button
96 type="button"
97 onClick={() => handleClick(part)}
98 class="cursor-pointer text-blue-500 hover:underline active:underline dark:text-blue-400"
99 >
100 {part}
101 </button>
102 : isCid(part) && props.isLink && ctx.parentIsBlob && params.repo ?
103 <A
104 class="text-blue-500 hover:underline active:underline dark:text-blue-400"
105 rel="noopener"
106 target="_blank"
107 href={`${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${params.repo}&cid=${part}`}
108 >
109 {part}
110 </A>
111 : (
112 isURL(part) &&
113 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) &&
114 part.split("\n").length === 1
115 ) ?
116 <a
117 class="underline hover:text-blue-500 dark:hover:text-blue-400"
118 href={part}
119 target="_blank"
120 rel="noopener"
121 >
122 {part}
123 </a>
124 : part}
125 </>
126 )}
127 </For>
128 <Show when={isTruncated()}>
129 <span>…</span>
130 </Show>
131 <span class="text-neutral-500 dark:text-neutral-400">"</span>
132 <Show when={isTruncated()}>
133 <span class="ml-1 text-neutral-500 dark:text-neutral-400">
134 (+{remainingChars().toLocaleString()})
135 </span>
136 </Show>
137 </span>
138 );
139};
140
141const JSONNumber = ({ data, isSize }: { data: number; isSize?: boolean }) => {
142 return (
143 <span class="flex gap-1">
144 {data}
145 <Show when={isSize}>
146 <span class="text-neutral-500 dark:text-neutral-400">({formatFileSize(data)})</span>
147 </Show>
148 </span>
149 );
150};
151
152const CollapsibleItem = (props: {
153 label: string | number;
154 value: JSONType;
155 maxWidth?: string;
156 isType?: boolean;
157 isLink?: boolean;
158 isSize?: boolean;
159 isIndex?: boolean;
160 parentIsBlob?: boolean;
161}) => {
162 const ctx = useJSONCtx();
163 const location = useLocation();
164 const [show, setShow] = createSignal(true);
165 const isBlobContext = props.parentIsBlob ?? ctx.parentIsBlob;
166
167 const labelStr = () => {
168 const l = String(props.label);
169 return l.startsWith("#") ? l.slice(1) : l;
170 };
171 const fullPath = () => (ctx.path ? `${ctx.path}.${labelStr()}` : labelStr());
172 const isHighlighted = () => location.hash === `#record:${fullPath()}`;
173
174 createEffect(() => {
175 if (isHighlighted()) {
176 requestAnimationFrame(() => {
177 document
178 .getElementById(`key-${fullPath()}`)
179 ?.scrollIntoView({ behavior: "instant", block: "center" });
180 });
181 }
182 });
183
184 const isObject = () => props.value === Object(props.value);
185 const isEmpty = () =>
186 Array.isArray(props.value) ?
187 (props.value as JSONType[]).length === 0
188 : Object.keys(props.value as object).length === 0;
189 const summary = () => {
190 if (Array.isArray(props.value)) {
191 const len = (props.value as JSONType[]).length;
192 return `[ ${len} ${len === 1 ? "item" : "items"} ]`;
193 }
194 const len = Object.keys(props.value as object).length;
195 return `{ ${len} ${len === 1 ? "key" : "keys"} }`;
196 };
197
198 return (
199 <span
200 classList={{
201 "group/indent flex gap-x-1 w-full": true,
202 "flex-col": isObject() && !isEmpty(),
203 }}
204 >
205 <span
206 class="relative flex size-fit shrink-0 items-center gap-x-1 wrap-anywhere"
207 classList={{ "max-w-[40%] sm:max-w-[50%]": props.maxWidth !== undefined && show() }}
208 >
209 <Show
210 when={ctx.keyLinks}
211 fallback={
212 <span
213 classList={{
214 "text-indigo-500 dark:text-indigo-400": !props.isIndex,
215 "text-violet-500 dark:text-violet-400": props.isIndex,
216 }}
217 >
218 {props.label}
219 <span class="text-neutral-500 dark:text-neutral-400">:</span>
220 </span>
221 }
222 >
223 <a
224 href={`#record:${fullPath()}`}
225 id={`key-${fullPath()}`}
226 class="group/key rounded"
227 classList={{
228 "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":
229 !props.isIndex && !isHighlighted(),
230 "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":
231 props.isIndex && !isHighlighted(),
232 "bg-indigo-200 text-indigo-700 dark:bg-indigo-500/60 dark:text-indigo-200":
233 isHighlighted() && !props.isIndex,
234 "bg-violet-200 text-violet-700 dark:bg-violet-500/60 dark:text-violet-200":
235 isHighlighted() && props.isIndex,
236 }}
237 >
238 <span class="absolute top-1/2 -left-3.5 flex -translate-y-1/2 items-center text-xs text-neutral-500 opacity-0 transition-opacity group-hover/key:opacity-100 dark:text-neutral-400">
239 <span class="iconify lucide--link"></span>
240 </span>
241 {props.label}
242 <span class="text-neutral-500 dark:text-neutral-400">:</span>
243 </a>
244 </Show>
245 <Show when={!show() && summary()}>
246 <button
247 type="button"
248 class="flex items-center gap-0.5 rounded bg-neutral-200 px-1 text-xs whitespace-nowrap text-neutral-500 hover:bg-neutral-300 hover:text-neutral-700 sm:py-0.5 dark:bg-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-600 dark:hover:text-neutral-200"
249 onclick={() => setShow(true)}
250 >
251 <span class="iconify lucide--chevron-right"></span>
252 {summary()}
253 </button>
254 </Show>
255 </span>
256 <span
257 classList={{
258 "self-center": !isObject() || isEmpty(),
259 "relative pl-[2ch]": isObject() && !isEmpty(),
260 "invisible h-0 overflow-hidden": !show(),
261 }}
262 >
263 <Show when={isObject() && !isEmpty()}>
264 <span
265 class="group/fold absolute inset-y-0 left-0 z-10 flex w-4 -translate-x-1/2 items-center justify-center"
266 onclick={() => setShow(!show())}
267 >
268 <span class="h-full w-px bg-neutral-300 transition-colors group-hover/fold:bg-neutral-600 dark:bg-neutral-600 dark:group-hover/fold:bg-neutral-300" />
269 </span>
270 </Show>
271 <JSONCtx.Provider value={{ ...ctx, parentIsBlob: isBlobContext, path: fullPath() }}>
272 <JSONValueInner
273 data={props.value}
274 isType={props.isType}
275 isLink={props.isLink}
276 isSize={props.isSize}
277 />
278 </JSONCtx.Provider>
279 </span>
280 </span>
281 );
282};
283
284const JSONObject = (props: { data: { [x: string]: JSONType } }) => {
285 const ctx = useJSONCtx();
286 const params = useParams();
287 const [hide, setHide] = createSignal(
288 localStorage.hideMedia === "true" || params.rkey === undefined,
289 );
290 createEffect(() => {
291 if (hideMedia()) setHide(hideMedia());
292 });
293
294 const isBlob = props.data.$type === "blob";
295 const isBlobContext = isBlob || ctx.parentIsBlob;
296
297 const rawObj = (
298 <For each={Object.entries(props.data)}>
299 {([key, value]) => (
300 <CollapsibleItem
301 label={key}
302 value={value}
303 maxWidth="set"
304 isType={key === "$type"}
305 isLink={key === "$link"}
306 isSize={key === "size" && isBlob}
307 parentIsBlob={isBlobContext}
308 />
309 )}
310 </For>
311 );
312
313 const blob: AtBlob = props.data as any;
314 const canShowMedia = () =>
315 ctx.pds &&
316 !ctx.hideBlobs &&
317 (blob.mimeType.startsWith("image/") ||
318 blob.mimeType === "video/mp4" ||
319 blob.mimeType.startsWith("audio/"));
320
321 const MediaDisplay = () => {
322 const [expanded, setExpanded] = createSignal(false);
323 const [closing, setClosing] = createSignal(false);
324
325 const closeExpanded = () => {
326 setClosing(true);
327 setTimeout(() => {
328 setExpanded(false);
329 setClosing(false);
330 }, 200);
331 };
332
333 createEffect(() => {
334 if (!expanded()) return;
335 const handler = (e: KeyboardEvent) => {
336 if (e.key === "Escape") closeExpanded();
337 };
338 window.addEventListener("keydown", handler);
339 onCleanup(() => window.removeEventListener("keydown", handler));
340 });
341 const [imageUrl] = createResource(
342 () => (blob.mimeType.startsWith("image/") ? blob.ref.$link : null),
343 async (cid) => {
344 const url = `${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`;
345
346 await new Promise<void>((resolve) => {
347 const img = new Image();
348 img.src = url;
349 img.onload = () => resolve();
350 img.onerror = () => resolve();
351 });
352
353 return url;
354 },
355 );
356
357 return (
358 <div>
359 <span class="group/media relative my-0.5 flex w-fit">
360 <Show when={!hide()}>
361 <Show when={blob.mimeType.startsWith("image/")}>
362 <Show
363 when={!imageUrl.loading && imageUrl()}
364 fallback={
365 <div class="flex h-48 w-48 items-center justify-center rounded bg-neutral-200 dark:bg-neutral-800">
366 <span class="iconify lucide--loader-circle animate-spin text-xl text-neutral-400 dark:text-neutral-500"></span>
367 </div>
368 }
369 >
370 <img
371 class="h-auto max-h-48 max-w-64 cursor-zoom-in object-contain"
372 src={imageUrl()}
373 onclick={() => setExpanded(true)}
374 />
375 <Show when={expanded()}>
376 <Portal>
377 <div
378 class="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/80 transition-opacity duration-200 starting:opacity-0"
379 classList={{ "opacity-0": closing() }}
380 onclick={closeExpanded}
381 >
382 <img
383 class="max-h-screen max-w-screen object-contain transition-all duration-200 starting:scale-95 starting:opacity-0"
384 classList={{ "scale-95 opacity-0": closing() }}
385 src={imageUrl()}
386 />
387 </div>
388 </Portal>
389 </Show>
390 </Show>
391 </Show>
392 <Show when={blob.mimeType === "video/mp4"}>
393 <ErrorBoundary fallback={() => <span>Failed to load video</span>}>
394 <VideoPlayer did={ctx.repo} cid={blob.ref.$link} />
395 </ErrorBoundary>
396 </Show>
397 <Show when={blob.mimeType.startsWith("audio/")}>
398 <audio class="my-0.5 max-w-96" controls>
399 <source
400 src={`${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${blob.ref.$link}`}
401 type={blob.mimeType === "audio/x-flac" ? "audio/flac" : blob.mimeType}
402 />
403 </audio>
404 </Show>
405 </Show>
406 <Show when={hide()}>
407 <button
408 onclick={() => setHide(false)}
409 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"
410 >
411 <span class="iconify lucide--image"></span>
412 <span class="font-sans">Show media</span>
413 </button>
414 </Show>
415 </span>
416 </div>
417 );
418 };
419
420 if (Object.keys(props.data).length === 0)
421 return <span class="text-neutral-400 dark:text-neutral-500">{"{ }"}</span>;
422
423 if (blob.$type === "blob") {
424 return (
425 <>
426 <Show when={canShowMedia()}>
427 <MediaDisplay />
428 </Show>
429 {rawObj}
430 </>
431 );
432 }
433
434 return rawObj;
435};
436
437const JSONArray = (props: { data: JSONType[] }) => {
438 if (props.data.length === 0)
439 return <span class="text-neutral-400 dark:text-neutral-500">[ ]</span>;
440 return (
441 <For each={props.data}>
442 {(value, index) => <CollapsibleItem label={`#${index()}`} value={value} isIndex />}
443 </For>
444 );
445};
446
447const JSONValueInner = (props: {
448 data: JSONType;
449 isType?: boolean;
450 isLink?: boolean;
451 isSize?: boolean;
452}) => {
453 const data = props.data;
454 if (typeof data === "string")
455 return <JSONString data={data} isType={props.isType} isLink={props.isLink} />;
456 if (typeof data === "number") return <JSONNumber data={data} isSize={props.isSize} />;
457 if (typeof data === "boolean")
458 return <span class="text-amber-500 dark:text-amber-400">{String(data)}</span>;
459 if (data === null) return <span class="text-neutral-400 dark:text-neutral-500">null</span>;
460 if (Array.isArray(data)) return <JSONArray data={data} />;
461 return <JSONObject data={data} />;
462};
463
464export const JSONValue = (props: {
465 data: JSONType;
466 repo: string;
467 pds?: string;
468 truncate?: boolean;
469 newTab?: boolean;
470 hideBlobs?: boolean;
471 keyLinks?: boolean;
472}) => {
473 return (
474 <JSONCtx.Provider
475 value={{
476 repo: props.repo,
477 pds: props.pds,
478 truncate: props.truncate,
479 newTab: props.newTab,
480 hideBlobs: props.hideBlobs,
481 keyLinks: props.keyLinks,
482 }}
483 >
484 <JSONValueInner data={props.data} />
485 </JSONCtx.Provider>
486 );
487};
488
489export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];