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