atmosphere explorer pdsls.dev
atproto tool typescript

Compare changes

Choose any two refs to compare.

+1 -1
src/components/theme.tsx
··· 43 43 44 44 return ( 45 45 <div class="flex flex-col gap-1"> 46 - <label class="select-none">Theme</label> 46 + <label class="font-medium select-none">Theme</label> 47 47 <div class="flex gap-2"> 48 48 <ThemeOption theme="auto" label="Auto" /> 49 49 <ThemeOption theme="light" label="Light" />
public/fonts/Figtree[wght].woff2 public/fonts/Figtree.woff2
+1 -1
src/styles/index.css
··· 8 8 9 9 @font-face { 10 10 font-family: "Figtree"; 11 - src: url("/fonts/Figtree[wght].woff2") format("woff2"); 11 + src: url("/fonts/Figtree.woff2") format("woff2"); 12 12 font-display: swap; 13 13 } 14 14
+1 -2
src/auth/account.tsx
··· 17 17 retrieveSession, 18 18 saveSessionToStorage, 19 19 } from "./session-manager.js"; 20 - import { agent, sessions, setAgent, setSessions } from "./state.js"; 20 + import { agent, openManager, sessions, setAgent, setOpenManager, setSessions } from "./state.js"; 21 21 22 22 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 23 const removeSession = async (did: Did) => { ··· 62 62 }; 63 63 64 64 export const AccountManager = () => { 65 - const [openManager, setOpenManager] = createSignal(false); 66 65 const [avatars, setAvatars] = createStore<Record<Did, string>>(); 67 66 const [showingAddAccount, setShowingAddAccount] = createSignal(false); 68 67
+1
src/auth/state.ts
··· 12 12 13 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 + export const [openManager, setOpenManager] = createSignal(false);
+11 -5
src/components/backlinks.tsx
··· 3 3 import { getAllBacklinks, getRecordBacklinks, LinksWithRecords } from "../utils/api.js"; 4 4 import { localDateFromTimestamp } from "../utils/date.js"; 5 5 import { Button } from "./button.jsx"; 6 + import { Favicon } from "./favicon.jsx"; 6 7 7 8 type BacklinksProps = { 8 9 target: string; ··· 122 123 ) => { 123 124 const [expanded, setExpanded] = createSignal(false); 124 125 126 + const authority = () => props.collection.split(".").slice(0, 2).join("."); 127 + 125 128 return ( 126 129 <div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700"> 127 130 <button 128 131 class="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800/50" 129 132 onClick={() => setExpanded(!expanded())} 130 133 > 131 - <div class="flex min-w-0 flex-1 flex-col"> 132 - <span class="w-full truncate">{props.collection}</span> 133 - <span class="w-full text-xs wrap-break-word text-neutral-500 dark:text-neutral-400"> 134 - {props.path.slice(1)} 135 - </span> 134 + <div class="flex min-w-0 flex-1 items-center gap-2"> 135 + <Favicon authority={authority()} /> 136 + <div class="flex min-w-0 flex-1 flex-col"> 137 + <span class="w-full truncate">{props.collection}</span> 138 + <span class="w-full text-xs wrap-break-word text-neutral-500 dark:text-neutral-400"> 139 + {props.path.slice(1)} 140 + </span> 141 + </div> 136 142 </div> 137 143 <div class="flex shrink-0 items-center gap-2 text-neutral-700 dark:text-neutral-300"> 138 144 <span class="text-xs">
+33
src/components/favicon.tsx
··· 1 + import { createSignal, JSX, Show } from "solid-js"; 2 + 3 + export const Favicon = (props: { 4 + authority: string; 5 + wrapper?: (children: JSX.Element) => JSX.Element; 6 + }) => { 7 + const [loaded, setLoaded] = createSignal(false); 8 + const domain = () => props.authority.split(".").reverse().join("."); 9 + 10 + const content = ( 11 + <> 12 + <Show when={!loaded()}> 13 + <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 14 + </Show> 15 + <img 16 + src={ 17 + ["bsky.app", "bsky.chat"].includes(domain()) ? 18 + "https://web-cdn.bsky.app/static/apple-touch-icon.png" 19 + : `https://${domain()}/favicon.ico` 20 + } 21 + alt="" 22 + class="h-4 w-4" 23 + classList={{ hidden: !loaded() }} 24 + onLoad={() => setLoaded(true)} 25 + onError={() => setLoaded(false)} 26 + /> 27 + </> 28 + ); 29 + 30 + return props.wrapper ? 31 + props.wrapper(content) 32 + : <div class="flex h-5 w-4 shrink-0 items-center justify-center">{content}</div>; 33 + };
+3
src/views/car/explore.tsx
··· 6 6 import { Title } from "@solidjs/meta"; 7 7 import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"; 8 8 import { Button } from "../../components/button.jsx"; 9 + import { Favicon } from "../../components/favicon.jsx"; 9 10 import { JSONValue } from "../../components/json.jsx"; 10 11 import { TextInput } from "../../components/text-input.jsx"; 11 12 import { isTouchDevice } from "../../layout.jsx"; ··· 309 310 <For each={filteredEntries()}> 310 311 {(entry) => { 311 312 const hasSingleEntry = entry.entries.length === 1; 313 + const authority = () => entry.name.split(".").slice(0, 2).join("."); 312 314 313 315 return ( 314 316 <li> ··· 326 328 }} 327 329 class="flex w-full items-center gap-2 rounded p-2 text-left text-sm hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-800 dark:active:bg-neutral-700" 328 330 > 331 + <Favicon authority={authority()} /> 329 332 <span 330 333 class="truncate font-medium" 331 334 classList={{
+1
src/utils/route-cache.ts
··· 5 5 cursor: string | undefined; 6 6 scrollY: number; 7 7 reverse: boolean; 8 + limit: number; 8 9 } 9 10 10 11 type RouteCache = Record<string, CollectionCacheEntry>;
+14 -4
src/views/collection.tsx
··· 40 40 toDelete: boolean; 41 41 } 42 42 43 - const LIMIT = 100; 43 + const DEFAULT_LIMIT = 100; 44 44 45 45 const RecordLink = (props: { record: AtprotoRecord }) => { 46 46 const [hover, setHover] = createSignal(false); ··· 98 98 const [batchDelete, setBatchDelete] = createSignal(false); 99 99 const [lastSelected, setLastSelected] = createSignal<number>(); 100 100 const [reverse, setReverse] = createSignal(searchParams.reverse === "true"); 101 + const limit = () => { 102 + const limitParam = 103 + Array.isArray(searchParams.limit) ? searchParams.limit[0] : searchParams.limit; 104 + const paramLimit = parseInt(limitParam || ""); 105 + return !isNaN(paramLimit) && paramLimit > 0 && paramLimit <= 100 ? paramLimit : DEFAULT_LIMIT; 106 + }; 101 107 const [recreate, setRecreate] = createSignal(false); 102 108 const [openDelete, setOpenDelete] = createSignal(false); 103 109 const [restoredFromCache, setRestoredFromCache] = createSignal(false); ··· 113 119 setRecords(cached.records as AtprotoRecord[]); 114 120 setCursor(cached.cursor); 115 121 setReverse(cached.reverse); 116 - setSearchParams({ reverse: cached.reverse ? "true" : undefined }); 122 + setSearchParams({ 123 + reverse: cached.reverse ? "true" : undefined, 124 + limit: cached.limit !== DEFAULT_LIMIT ? cached.limit.toString() : undefined, 125 + }); 117 126 setRestoredFromCache(true); 118 127 requestAnimationFrame(() => { 119 128 window.scrollTo(0, cached.scrollY); ··· 131 140 cursor: cursor(), 132 141 scrollY: window.scrollY, 133 142 reverse: reverse(), 143 + limit: limit(), 134 144 }); 135 145 } else { 136 146 clearCollectionCache(cacheKey()); ··· 152 162 params: { 153 163 repo: did as ActorIdentifier, 154 164 collection: params.collection as `${string}.${string}.${string}`, 155 - limit: LIMIT, 165 + limit: limit(), 156 166 cursor: cursor(), 157 167 reverse: reverse(), 158 168 }, 159 169 }); 160 170 if (!res.ok) throw new Error(res.data.error); 161 - setCursor(res.data.records.length < LIMIT ? undefined : res.data.cursor); 171 + setCursor(res.data.records.length < limit() ? undefined : res.data.cursor); 162 172 const tmpRecords: AtprotoRecord[] = []; 163 173 res.data.records.forEach((record) => { 164 174 const rkey = record.uri.split("/").pop()!;
+8 -3
src/auth/oauth-config.ts
··· 1 - import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client"; 1 + import { LocalActorResolver } from "@atcute/identity-resolver"; 2 + import { configureOAuth } from "@atcute/oauth-browser-client"; 2 3 import { didDocumentResolver, handleResolver } from "../utils/api"; 3 4 5 + const reactiveDidDocumentResolver = { 6 + resolve: async (did: string) => didDocumentResolver().resolve(did as any), 7 + }; 8 + 4 9 configureOAuth({ 5 10 metadata: { 6 11 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 7 12 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 8 13 }, 9 - identityResolver: defaultIdentityResolver({ 14 + identityResolver: new LocalActorResolver({ 10 15 handleResolver: handleResolver, 11 - didDocumentResolver: didDocumentResolver, 16 + didDocumentResolver: reactiveDidDocumentResolver, 12 17 }), 13 18 });
+3 -4
src/layout.tsx
··· 12 12 import { Search, SearchButton, showSearch } from "./components/search.jsx"; 13 13 import { themeEvent } from "./components/theme.jsx"; 14 14 import { resolveHandle } from "./utils/api.js"; 15 + import { plcDirectory } from "./views/settings.jsx"; 15 16 16 17 export const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 1; 17 18 ··· 187 188 </Show> 188 189 </div> 189 190 <NotificationContainer /> 190 - <Show 191 - when={localStorage.plcDirectory && localStorage.plcDirectory !== "https://plc.directory"} 192 - > 191 + <Show when={plcDirectory() !== "https://plc.directory"}> 193 192 <div class="dark:bg-dark-500 fixed right-0 bottom-0 left-0 z-10 flex items-center justify-center bg-neutral-100 px-3 py-1 text-xs"> 194 193 <span> 195 - PLC directory: <span class="font-medium">{localStorage.plcDirectory}</span> 194 + PLC directory: <span class="font-medium">{plcDirectory()}</span> 196 195 </span> 197 196 </div> 198 197 </Show>
+22 -14
src/utils/api.ts
··· 16 16 import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver"; 17 17 import { Did, Handle } from "@atcute/lexicons"; 18 18 import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax"; 19 + import { createMemo } from "solid-js"; 19 20 import { createStore } from "solid-js/store"; 20 21 import { setPDS } from "../components/navbar"; 21 - 22 - export const didDocumentResolver = new CompositeDidDocumentResolver({ 23 - methods: { 24 - plc: new PlcDidDocumentResolver({ 25 - apiUrl: localStorage.getItem("plcDirectory") ?? "https://plc.directory", 22 + import { plcDirectory } from "../views/settings"; 23 + 24 + export const didDocumentResolver = createMemo( 25 + () => 26 + new CompositeDidDocumentResolver({ 27 + methods: { 28 + plc: new PlcDidDocumentResolver({ 29 + apiUrl: plcDirectory(), 30 + }), 31 + web: new AtprotoWebDidDocumentResolver(), 32 + }, 26 33 }), 27 - web: new AtprotoWebDidDocumentResolver(), 28 - }, 29 - }); 34 + ); 30 35 31 36 export const handleResolver = new CompositeHandleResolver({ 32 37 strategy: "dns-first", ··· 40 45 dohUrl: "https://dns.google/resolve?", 41 46 }); 42 47 43 - const schemaResolver = new LexiconSchemaResolver({ 44 - didDocumentResolver: didDocumentResolver, 45 - }); 48 + const schemaResolver = createMemo( 49 + () => 50 + new LexiconSchemaResolver({ 51 + didDocumentResolver: didDocumentResolver(), 52 + }), 53 + ); 46 54 47 55 const didPDSCache: Record<string, string> = {}; 48 56 const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({}); ··· 54 62 throw new Error("Not a valid DID identifier"); 55 63 } 56 64 57 - const doc = await didDocumentResolver.resolve(did); 65 + const doc = await didDocumentResolver().resolve(did); 58 66 didDocCache[did] = doc; 59 67 60 68 const pds = getPdsEndpoint(doc); ··· 83 91 if (!isAtprotoDid(did)) { 84 92 throw new Error("Not a valid DID identifier"); 85 93 } 86 - return await didDocumentResolver.resolve(did); 94 + return await didDocumentResolver().resolve(did); 87 95 }; 88 96 89 97 const validateHandle = async (handle: Handle, did: Did) => { ··· 145 153 }; 146 154 147 155 const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => { 148 - return await schemaResolver.resolve(authority, nsid); 156 + return await schemaResolver().resolve(authority, nsid); 149 157 }; 150 158 151 159 interface LinkData {
+2 -3
src/views/logs.tsx
··· 8 8 import { createEffect, createResource, createSignal, For, Show } from "solid-js"; 9 9 import { localDateFromTimestamp } from "../utils/date.js"; 10 10 import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 11 + import { plcDirectory } from "./settings.jsx"; 11 12 12 13 type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; 13 14 ··· 23 24 !activePlcEvent() || diffs.some((d) => d.type.startsWith(activePlcEvent()!)); 24 25 25 26 const fetchPlcLogs = async () => { 26 - const res = await fetch( 27 - `${localStorage.plcDirectory ?? "https://plc.directory"}/${props.did}/log/audit`, 28 - ); 27 + const res = await fetch(`${plcDirectory()}/${props.did}/log/audit`); 29 28 const json = await res.json(); 30 29 const logs = defs.indexedEntryLog.parse(json); 31 30 setRawLogs(logs);
+1 -1
src/views/record.tsx
··· 57 57 const schemaPromise = (async () => { 58 58 let didDocPromise = documentCache.get(authority); 59 59 if (!didDocPromise) { 60 - didDocPromise = didDocumentResolver.resolve(authority); 60 + didDocPromise = didDocumentResolver().resolve(authority); 61 61 documentCache.set(authority, didDocPromise); 62 62 } 63 63
+4 -5
src/views/repo.tsx
··· 42 42 import { detectDidKeyType, detectKeyType } from "../utils/key.js"; 43 43 import { BlobView } from "./blob.jsx"; 44 44 import { PlcLogView } from "./logs.jsx"; 45 + import { plcDirectory } from "./settings.jsx"; 45 46 46 47 export const RepoView = () => { 47 48 const params = useParams(); ··· 101 102 }; 102 103 103 104 const getRotationKeys = async () => { 104 - const res = await fetch( 105 - `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/last`, 106 - ); 105 + const res = await fetch(`${plcDirectory()}/${did}/log/last`); 107 106 const json = await res.json(); 108 107 setRotationKeys(json.rotationKeys ?? []); 109 108 }; ··· 364 363 <NavMenu 365 364 href={ 366 365 did.startsWith("did:plc") ? 367 - `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 366 + `${plcDirectory()}/${did}` 368 367 : `https://${did.split("did:web:")[1]}/.well-known/did.json` 369 368 } 370 369 newTab ··· 373 372 /> 374 373 <Show when={did.startsWith("did:plc")}> 375 374 <NavMenu 376 - href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`} 375 + href={`${plcDirectory()}/${did}/log/audit`} 377 376 newTab 378 377 label="Audit log" 379 378 icon="lucide--external-link"
+1
src/components/search.tsx
··· 92 92 const handlePaste = (e: ClipboardEvent) => { 93 93 if (e.target === searchInput) return; 94 94 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 95 + if (document.querySelector("[data-modal]")) return; 95 96 96 97 const pastedText = e.clipboardData?.getData("text"); 97 98 if (pastedText) processInput(pastedText);
+1 -1
LICENSE
··· 1 - Copyright (c) 2024-2025 Juliet Philippe <m@juli.ee> 1 + Copyright (c) 2024-2026 Juliet Philippe <m@juli.ee> 2 2 3 3 Permission to use, copy, modify, and/or distribute this software for any 4 4 purpose with or without fee is hereby granted.
+2 -1
README.md
··· 1 - # PDSls - AT Protocol Explorer 1 + # PDSls - Atmosphere Explorer 2 2 3 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 4 ··· 9 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 11 - Query moderation labels. 12 + - Explore and unpack repository archives (CAR). 12 13 13 14 ## Hacking 14 15
+2 -2
src/views/labels.tsx
··· 137 137 }); 138 138 139 139 const fetchLabels = async (formData: FormData, reset?: boolean) => { 140 - let did = formData.get("did")?.toString()?.trim(); 140 + let did = formData.get("did")?.toString()?.trim() || "did:plc:ar7c4by46qjdydhdevvrndac"; 141 141 const uriPatterns = formData.get("uriPatterns")?.toString()?.trim(); 142 142 143 143 if (!did || !uriPatterns) { ··· 215 215 name="did" 216 216 value={didInput()} 217 217 onInput={(e) => setDidInput(e.currentTarget.value)} 218 - placeholder="did:plc:..." 218 + placeholder="moderation.bsky.app (default)" 219 219 class="w-full" 220 220 /> 221 221 </label>