1import VideoPlayer from "./video-player";
2import { createEffect, createSignal, For, Show } from "solid-js";
3import { A } from "@solidjs/router";
4import { pds } from "./navbar";
5import Tooltip from "./tooltip";
6import { hideMedia } from "./settings";
7
8interface AtBlob {
9 $type: string;
10 ref: { $link: string };
11 mimeType: string;
12}
13
14const ATURI_RE =
15 /^at:\/\/([a-zA-Z0-9._:%-]+)(?:\/([a-zA-Z0-9-.]+)(?:\/([a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(?:#(\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/;
16
17const DID_RE = /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/;
18
19const JSONString = ({ data }: { data: string }) => {
20 const isURL =
21 URL.canParse ??
22 ((url, base) => {
23 try {
24 new URL(url, base);
25 return true;
26 } catch {
27 return false;
28 }
29 });
30
31 return (
32 <span>
33 "
34 <For each={data.split(/(\s)/)}>
35 {(part) => (
36 <>
37 {ATURI_RE.test(part) ?
38 <A class="text-blue-400 hover:underline" href={`/${part}`}>
39 {part}
40 </A>
41 : DID_RE.test(part) ?
42 <A class="text-blue-400 hover:underline" href={`/at://${part}`}>
43 {part}
44 </A>
45 : (
46 isURL(part) &&
47 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) &&
48 part.split("\n").length === 1
49 ) ?
50 <a
51 class="text-blue-400 hover:underline"
52 href={part}
53 target="_blank"
54 rel="noopener noreferrer"
55 >
56 {part}
57 </a>
58 : part}
59 </>
60 )}
61 </For>
62 "
63 </span>
64 );
65};
66
67const JSONNumber = ({ data }: { data: number }) => {
68 return <span>{data}</span>;
69};
70
71const JSONBoolean = ({ data }: { data: boolean }) => {
72 return <span>{data ? "true" : "false"}</span>;
73};
74
75const JSONNull = () => {
76 return <span>null</span>;
77};
78
79const JSONObject = ({ data, repo }: { data: { [x: string]: JSONType }; repo: string }) => {
80 const [hide, setHide] = createSignal(localStorage.hideMedia === "true");
81
82 createEffect(() => setHide(hideMedia()));
83
84 const Obj = ({ key, value }: { key: string; value: JSONType }) => {
85 const [show, setShow] = createSignal(true);
86
87 return (
88 <span
89 classList={{
90 "group/indent flex gap-x-1 w-full": true,
91 "flex-col": value === Object(value),
92 }}
93 >
94 <button
95 class="max-w-40% sm:max-w-50% break-anywhere group/clip relative flex size-fit shrink-0 items-center text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300"
96 onclick={() => setShow(!show())}
97 >
98 <span
99 classList={{
100 "dark:bg-dark-500 absolute w-5 -left-5 bg-zinc-100 text-sm": true,
101 "hidden group-hover/clip:block": show(),
102 }}
103 >
104 {show() ?
105 <div class="i-lucide-chevron-down" />
106 : <div class="i-lucide-chevron-right" />}
107 </span>
108 {key}:
109 </button>
110 <span
111 classList={{
112 "self-center": value !== Object(value),
113 "pl-[calc(2ch-1px)] border-l-0.5 border-neutral-500/50 dark:border-neutral-400/50 has-hover:group-hover/indent:border-neutral-700 dark:has-hover:group-hover/indent:border-neutral-300":
114 value === Object(value),
115 "invisible h-0": !show(),
116 }}
117 >
118 <JSONValue data={value} repo={repo} />
119 </span>
120 </span>
121 );
122 };
123
124 const rawObj = (
125 <For each={Object.entries(data)}>{([key, value]) => <Obj key={key} value={value} />}</For>
126 );
127
128 const blob: AtBlob = data as any;
129
130 if (blob.$type === "blob") {
131 return (
132 <>
133 <span class="flex gap-x-1">
134 <Show when={blob.mimeType.startsWith("image/") && !hide()}>
135 <a
136 href={`https://cdn.bsky.app/img/feed_thumbnail/plain/${repo}/${blob.ref.$link}@jpeg`}
137 target="_blank"
138 >
139 <img
140 class="max-h-[16rem] w-full max-w-[16rem]"
141 src={`https://cdn.bsky.app/img/feed_thumbnail/plain/${repo}/${blob.ref.$link}@jpeg`}
142 />
143 </a>
144 </Show>
145 <Show when={blob.mimeType === "video/mp4" && !hide()}>
146 <VideoPlayer did={repo} cid={blob.ref.$link} />
147 </Show>
148 <span
149 classList={{ "flex items-center justify-between gap-2": true, "flex-col": !hide() }}
150 >
151 <Show when={blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4"}>
152 <Tooltip text={hide() ? "Show" : "Hide"}>
153 <button onclick={() => setHide(!hide())}>
154 <div class={`text-lg ${hide() ? "i-lucide-eye-off" : "i-lucide-eye"}`} />
155 </button>
156 </Tooltip>
157 </Show>
158 <Show when={pds()}>
159 <a
160 href={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${repo}&cid=${blob.ref.$link}`}
161 target="_blank"
162 class="size-fit"
163 >
164 <Tooltip text="Blob link">
165 <div class="i-lucide-external-link text-lg" />
166 </Tooltip>
167 </a>
168 </Show>
169 </span>
170 </span>
171 {rawObj}
172 </>
173 );
174 }
175
176 return rawObj;
177};
178
179const JSONArray = ({ data, repo }: { data: JSONType[]; repo: string }) => {
180 return (
181 <For each={data}>
182 {(value, index) => (
183 <span
184 classList={{
185 "flex before:content-['-']": true,
186 "mb-2": value === Object(value) && index() !== data.length - 1,
187 }}
188 >
189 <span class="ml-[1ch] w-full">
190 <JSONValue data={value} repo={repo} />
191 </span>
192 </span>
193 )}
194 </For>
195 );
196};
197
198export const JSONValue = ({ data, repo }: { data: JSONType; repo: string }) => {
199 if (typeof data === "string") return <JSONString data={data} />;
200 if (typeof data === "number") return <JSONNumber data={data} />;
201 if (typeof data === "boolean") return <JSONBoolean data={data} />;
202 if (data === null) return <JSONNull />;
203 if (Array.isArray(data)) return <JSONArray data={data} repo={repo} />;
204 return <JSONObject data={data} repo={repo} />;
205};
206
207export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];