atproto explorer pdsls.dev
atproto tool
1import { isDid, isNsid, Nsid } from "@atcute/lexicons/syntax"; 2import { A, useNavigate, useParams } from "@solidjs/router"; 3import { createEffect, createSignal, ErrorBoundary, For, Show } from "solid-js"; 4import { setNotif } from "../layout"; 5import { resolveLexiconAuthority } from "../utils/api"; 6import { ATURI_RE } from "../utils/types/at-uri"; 7import { hideMedia } from "../views/settings"; 8import { pds } from "./navbar"; 9import Tooltip from "./tooltip"; 10import VideoPlayer from "./video-player"; 11 12interface AtBlob { 13 $type: string; 14 ref: { $link: string }; 15 mimeType: string; 16} 17 18const JSONString = ({ data, isType }: { data: string; isType?: boolean }) => { 19 const navigate = useNavigate(); 20 21 const isURL = 22 URL.canParse ?? 23 ((url, base) => { 24 try { 25 new URL(url, base); 26 return true; 27 } catch { 28 return false; 29 } 30 }); 31 32 const handleClick = async (lex: string) => { 33 try { 34 const [nsid, anchor] = lex.split("#"); 35 const authority = await resolveLexiconAuthority(nsid as Nsid); 36 37 const hash = anchor ? `#schema:${anchor}` : "#schema"; 38 navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 39 } catch (err) { 40 console.error("Failed to resolve lexicon authority:", err); 41 setNotif({ 42 show: true, 43 icon: "lucide--circle-alert", 44 text: "Could not resolve schema", 45 }); 46 } 47 }; 48 49 return ( 50 <span> 51 " 52 <For each={data.split(/(\s)/)}> 53 {(part) => ( 54 <> 55 {ATURI_RE.test(part) ? 56 <A class="text-blue-400 hover:underline active:underline" href={`/${part}`}> 57 {part} 58 </A> 59 : isDid(part) ? 60 <A class="text-blue-400 hover:underline active:underline" href={`/at://${part}`}> 61 {part} 62 </A> 63 : isNsid(part.split("#")[0]) && isType ? 64 <button 65 type="button" 66 onClick={() => handleClick(part)} 67 class="cursor-pointer text-blue-400 hover:underline active:underline" 68 > 69 {part} 70 </button> 71 : ( 72 isURL(part) && 73 ["http:", "https:", "web+at:"].includes(new URL(part).protocol) && 74 part.split("\n").length === 1 75 ) ? 76 <a class="underline" href={part} target="_blank" rel="noopener"> 77 {part} 78 </a> 79 : part} 80 </> 81 )} 82 </For> 83 " 84 </span> 85 ); 86}; 87 88const JSONNumber = ({ data }: { data: number }) => { 89 return <span>{data}</span>; 90}; 91 92const JSONBoolean = ({ data }: { data: boolean }) => { 93 return <span>{data ? "true" : "false"}</span>; 94}; 95 96const JSONNull = () => { 97 return <span>null</span>; 98}; 99 100const JSONObject = ({ data, repo }: { data: { [x: string]: JSONType }; repo: string }) => { 101 const params = useParams(); 102 const [hide, setHide] = createSignal( 103 localStorage.hideMedia === "true" || params.rkey === undefined, 104 ); 105 106 createEffect(() => { 107 if (hideMedia()) setHide(hideMedia()); 108 }); 109 110 const Obj = ({ key, value }: { key: string; value: JSONType }) => { 111 const [show, setShow] = createSignal(true); 112 113 return ( 114 <span 115 classList={{ 116 "group/indent flex gap-x-1 w-full": true, 117 "flex-col": value === Object(value), 118 }} 119 > 120 <button 121 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" 122 onclick={() => setShow(!show())} 123 > 124 <span 125 classList={{ 126 "dark:bg-dark-500 absolute w-5 flex items-center -left-5 bg-neutral-100 text-sm": true, 127 "hidden group-hover/clip:flex": show(), 128 }} 129 > 130 {show() ? 131 <span class="iconify lucide--chevron-down"></span> 132 : <span class="iconify lucide--chevron-right"></span>} 133 </span> 134 {key}: 135 </button> 136 <span 137 classList={{ 138 "self-center": value !== Object(value), 139 "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 dark:has-hover:group-hover/indent:border-neutral-300": 140 value === Object(value), 141 "invisible h-0": !show(), 142 }} 143 > 144 <JSONValue data={value} repo={repo} isType={key === "$type" ? true : undefined} /> 145 </span> 146 </span> 147 ); 148 }; 149 150 const rawObj = ( 151 <For each={Object.entries(data)}>{([key, value]) => <Obj key={key} value={value} />}</For> 152 ); 153 154 const blob: AtBlob = data as any; 155 156 if (blob.$type === "blob") { 157 return ( 158 <> 159 <Show when={pds() && params.rkey}> 160 <span class="flex gap-x-1"> 161 <Show when={blob.mimeType.startsWith("image/") && !hide()}> 162 <img 163 class="h-auto max-h-64 max-w-[16rem] object-contain" 164 src={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${repo}&cid=${blob.ref.$link}`} 165 /> 166 </Show> 167 <Show when={blob.mimeType === "video/mp4" && !hide()}> 168 <ErrorBoundary fallback={() => <span>Failed to load video</span>}> 169 <VideoPlayer did={repo} cid={blob.ref.$link} /> 170 </ErrorBoundary> 171 </Show> 172 <span 173 classList={{ 174 "flex items-center justify-between gap-1": true, 175 "flex-col": !hide(), 176 }} 177 > 178 <Show when={blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4"}> 179 <Tooltip text={hide() ? "Show" : "Hide"}> 180 <button 181 onclick={() => setHide(!hide())} 182 class={`${!hide() ? "-mt-1 -ml-0.5" : ""} flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 183 > 184 <span 185 class={`iconify text-base ${hide() ? "lucide--eye-off" : "lucide--eye"}`} 186 ></span> 187 </button> 188 </Tooltip> 189 </Show> 190 <Tooltip text="Blob on PDS"> 191 <a 192 href={`https://${pds()}/xrpc/com.atproto.sync.getBlob?did=${repo}&cid=${blob.ref.$link}`} 193 target="_blank" 194 class={`${!hide() ? "-mb-1 -ml-0.5" : ""} flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 195 > 196 <span class="iconify lucide--external-link text-base"></span> 197 </a> 198 </Tooltip> 199 </span> 200 </span> 201 </Show> 202 {rawObj} 203 </> 204 ); 205 } 206 207 return rawObj; 208}; 209 210const JSONArray = ({ data, repo }: { data: JSONType[]; repo: string }) => { 211 return ( 212 <For each={data}> 213 {(value, index) => ( 214 <span 215 classList={{ 216 "flex before:content-['-']": true, 217 "mb-2": value === Object(value) && index() !== data.length - 1, 218 }} 219 > 220 <span class="ml-[1ch] w-full"> 221 <JSONValue data={value} repo={repo} /> 222 </span> 223 </span> 224 )} 225 </For> 226 ); 227}; 228 229export const JSONValue = (props: { data: JSONType; repo: string; isType?: boolean }) => { 230 const data = props.data; 231 if (typeof data === "string") return <JSONString data={data} isType={props.isType} />; 232 if (typeof data === "number") return <JSONNumber data={data} />; 233 if (typeof data === "boolean") return <JSONBoolean data={data} />; 234 if (data === null) return <JSONNull />; 235 if (Array.isArray(data)) return <JSONArray data={data} repo={props.repo} />; 236 return <JSONObject data={data} repo={props.repo} />; 237}; 238 239export type JSONType = string | number | boolean | null | { [x: string]: JSONType } | JSONType[];