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