+1
-1
src/components/theme.tsx
+1
-1
src/components/theme.tsx
public/fonts/Figtree[wght].woff2
public/fonts/Figtree.woff2
public/fonts/Figtree[wght].woff2
public/fonts/Figtree.woff2
+1
-1
src/styles/index.css
+1
-1
src/styles/index.css
+1
-2
src/auth/account.tsx
+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
+1
src/auth/state.ts
+11
-5
src/components/backlinks.tsx
+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
+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
+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
+1
src/utils/route-cache.ts
+14
-4
src/views/collection.tsx
+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
+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
+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
+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
+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
+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
+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
+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
-1
LICENSE
+2
-1
README.md
+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
+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>