forked from
pds.ls/pdsls
atmosphere explorer
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[];