tangled
alpha
login
or
join now
esponcho.me
/
pdsls
forked from
pds.ls/pdsls
this repo has no description
0
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
plc-watch1
plc-watch
main
no tags found
compare:
plc-watch1
plc-watch
main
no tags found
go
+77
-1821
12 changed files
expand all
collapse all
unified
split
src
components
button.tsx
index.tsx
layout.tsx
views
blob.tsx
collection.tsx
enroll.tsx
labels.tsx
pds.tsx
record.tsx
repo.tsx
settings.tsx
stream.tsx
+4
-1
src/components/button.tsx
···
5
5
classList?: Record<string, boolean | undefined>;
6
6
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
7
7
children?: JSX.Element;
8
8
+
type?: "button" | "submit" | "reset";
9
9
+
disabled?: boolean;
8
10
}
9
11
10
12
export const Button = (props: ButtonProps) => {
11
13
return (
12
14
<button
13
13
-
type="button"
15
15
+
type={props.type ?? "button"}
16
16
+
disabled={props.disabled}
14
17
class={
15
18
props.class ??
16
19
"dark:hover:bg-dark-200 dark:shadow-dark-800 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs font-semibold shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
+2
-14
src/index.tsx
···
3
3
import { render } from "solid-js/web";
4
4
import { Layout } from "./layout.tsx";
5
5
import "./styles/index.css";
6
6
-
import { CollectionView } from "./views/collection.tsx";
7
6
import { Home } from "./views/home.tsx";
8
8
-
import { LabelView } from "./views/labels.tsx";
9
9
-
import { PdsView } from "./views/pds.tsx";
10
10
-
import { RecordView } from "./views/record.tsx";
11
11
-
import { RepoView } from "./views/repo.tsx";
12
12
-
import { Settings } from "./views/settings.tsx";
13
13
-
import { StreamView } from "./views/stream.tsx";
7
7
+
import { EnrollView } from "./views/enroll.tsx";
14
8
15
9
render(
16
10
() => (
17
11
<Router root={Layout}>
18
12
<Route path="/" component={Home} />
19
19
-
<Route path={["/jetstream", "/firehose"]} component={StreamView} />
20
20
-
<Route path="/settings" component={Settings} />
21
21
-
<Route path="/:pds" component={PdsView} />
22
22
-
<Route path="/:pds/:repo" component={RepoView} />
23
23
-
<Route path="/:pds/:repo/labels" component={LabelView} />
24
24
-
<Route path="/:pds/:repo/:collection" component={CollectionView} />
25
25
-
<Route path="/:pds/:repo/:collection/:rkey" component={RecordView} />
13
13
+
<Route path="/:pds/:repo" component={EnrollView} />
26
14
</Router>
27
15
),
28
16
document.getElementById("root") as HTMLElement,
-13
src/layout.tsx
···
68
68
<Show when={agent()}>
69
69
<RecordEditor create={true} />
70
70
</Show>
71
71
-
<AccountManager />
72
72
-
<MenuProvider>
73
73
-
<DropdownMenu
74
74
-
icon="lucide--menu text-xl"
75
75
-
buttonClass="rounded-lg p-1"
76
76
-
menuClass="top-8 p-3 text-sm"
77
77
-
>
78
78
-
<NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" />
79
79
-
<NavMenu href="/firehose" label="Firehose" icon="lucide--waves" />
80
80
-
<NavMenu href="/settings" label="Settings" icon="lucide--settings" />
81
81
-
<ThemeSelection />
82
82
-
</DropdownMenu>
83
83
-
</MenuProvider>
84
71
</div>
85
72
</header>
86
73
<div class="flex max-w-full min-w-[22rem] flex-col items-center gap-4 text-pretty sm:min-w-[24rem] md:max-w-[48rem]">
-64
src/views/blob.tsx
···
1
1
-
import { Client, CredentialManager } from "@atcute/client";
2
2
-
import { createResource, createSignal, For, Show } from "solid-js";
3
3
-
import { Button } from "../components/button";
4
4
-
5
5
-
const LIMIT = 1000;
6
6
-
7
7
-
const BlobView = (props: { pds: string; repo: string }) => {
8
8
-
const [cursor, setCursor] = createSignal<string>();
9
9
-
let rpc: Client;
10
10
-
11
11
-
const fetchBlobs = async () => {
12
12
-
if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: props.pds }) });
13
13
-
const res = await rpc.get("com.atproto.sync.listBlobs", {
14
14
-
params: {
15
15
-
did: props.repo as `did:${string}:${string}`,
16
16
-
limit: LIMIT,
17
17
-
cursor: cursor(),
18
18
-
},
19
19
-
});
20
20
-
if (!res.ok) throw new Error(res.data.error);
21
21
-
if (!res.data.cids) return [];
22
22
-
setCursor(res.data.cids.length < LIMIT ? undefined : res.data.cursor);
23
23
-
setBlobs(blobs()?.concat(res.data.cids) ?? res.data.cids);
24
24
-
return res.data.cids;
25
25
-
};
26
26
-
27
27
-
const [response, { refetch }] = createResource(fetchBlobs);
28
28
-
const [blobs, setBlobs] = createSignal<string[]>();
29
29
-
30
30
-
return (
31
31
-
<div class="flex flex-col items-center gap-2">
32
32
-
<Show when={blobs() || response()}>
33
33
-
<p>
34
34
-
{blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""}
35
35
-
</p>
36
36
-
<div class="flex flex-col gap-0.5 font-mono text-sm wrap-anywhere lg:break-normal">
37
37
-
<For each={blobs()}>
38
38
-
{(cid) => (
39
39
-
<a
40
40
-
href={`${props.pds}/xrpc/com.atproto.sync.getBlob?did=${props.repo}&cid=${cid}`}
41
41
-
target="_blank"
42
42
-
class="rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
43
43
-
>
44
44
-
<span class="text-blue-400">{cid}</span>
45
45
-
</a>
46
46
-
)}
47
47
-
</For>
48
48
-
</div>
49
49
-
</Show>
50
50
-
<Show when={cursor()}>
51
51
-
<div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-3">
52
52
-
<Show when={!response.loading}>
53
53
-
<Button onClick={() => refetch()}>Load More</Button>
54
54
-
</Show>
55
55
-
<Show when={response.loading}>
56
56
-
<span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span>
57
57
-
</Show>
58
58
-
</div>
59
59
-
</Show>
60
60
-
</div>
61
61
-
);
62
62
-
};
63
63
-
64
64
-
export { BlobView };
-354
src/views/collection.tsx
···
1
1
-
import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto";
2
2
-
import { Client, CredentialManager } from "@atcute/client";
3
3
-
import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons";
4
4
-
import * as TID from "@atcute/tid";
5
5
-
import { A, useParams } from "@solidjs/router";
6
6
-
import { createEffect, createResource, createSignal, For, Show, untrack } from "solid-js";
7
7
-
import { createStore } from "solid-js/store";
8
8
-
import { Button } from "../components/button.jsx";
9
9
-
import { JSONType, JSONValue } from "../components/json.jsx";
10
10
-
import { agent } from "../components/login.jsx";
11
11
-
import { Modal } from "../components/modal.jsx";
12
12
-
import { StickyOverlay } from "../components/sticky.jsx";
13
13
-
import { TextInput } from "../components/text-input.jsx";
14
14
-
import Tooltip from "../components/tooltip.jsx";
15
15
-
import { setNotif } from "../layout.jsx";
16
16
-
import { resolvePDS } from "../utils/api.js";
17
17
-
import { localDateFromTimestamp } from "../utils/date.js";
18
18
-
19
19
-
interface AtprotoRecord {
20
20
-
rkey: string;
21
21
-
record: InferXRPCBodyOutput<ComAtprotoRepoGetRecord.mainSchema["output"]>;
22
22
-
timestamp: number | undefined;
23
23
-
toDelete: boolean;
24
24
-
}
25
25
-
26
26
-
const LIMIT = 100;
27
27
-
28
28
-
const RecordLink = (props: { record: AtprotoRecord }) => {
29
29
-
const [hover, setHover] = createSignal(false);
30
30
-
const [previewHeight, setPreviewHeight] = createSignal(0);
31
31
-
let rkeyRef!: HTMLSpanElement;
32
32
-
let previewRef!: HTMLSpanElement;
33
33
-
34
34
-
createEffect(() => {
35
35
-
if (hover()) setPreviewHeight(previewRef.offsetHeight);
36
36
-
});
37
37
-
38
38
-
const isOverflowing = (previewHeight: number) =>
39
39
-
rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight;
40
40
-
41
41
-
return (
42
42
-
<span
43
43
-
class="relative flex items-baseline rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
44
44
-
ref={rkeyRef}
45
45
-
onmouseover={() => setHover(true)}
46
46
-
onmouseleave={() => setHover(false)}
47
47
-
>
48
48
-
<span class="text-sm text-blue-400 sm:text-base">{props.record.rkey}</span>
49
49
-
<Show when={props.record.timestamp && props.record.timestamp <= Date.now()}>
50
50
-
<span class="ml-1 text-xs text-neutral-500 dark:text-neutral-400">
51
51
-
{localDateFromTimestamp(props.record.timestamp!)}
52
52
-
</span>
53
53
-
</Show>
54
54
-
<Show when={hover()}>
55
55
-
<span
56
56
-
ref={previewRef}
57
57
-
class={`dark:bg-dark-300 dark:shadow-dark-800 pointer-events-none absolute left-[50%] z-25 block max-h-[20rem] w-max max-w-sm -translate-x-1/2 overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 text-xs whitespace-pre-wrap shadow-md sm:max-h-[28rem] lg:max-w-lg dark:border-neutral-700 ${isOverflowing(previewHeight()) ? "bottom-7" : "top-7"}`}
58
58
-
>
59
59
-
<JSONValue
60
60
-
data={props.record.record.value as JSONType}
61
61
-
repo={props.record.record.uri.split("/")[2]}
62
62
-
/>
63
63
-
</span>
64
64
-
</Show>
65
65
-
</span>
66
66
-
);
67
67
-
};
68
68
-
69
69
-
const CollectionView = () => {
70
70
-
const params = useParams();
71
71
-
const [cursor, setCursor] = createSignal<string>();
72
72
-
const [records, setRecords] = createStore<AtprotoRecord[]>([]);
73
73
-
const [filter, setFilter] = createSignal<string>();
74
74
-
const [batchDelete, setBatchDelete] = createSignal(false);
75
75
-
const [lastSelected, setLastSelected] = createSignal<number>();
76
76
-
const [reverse, setReverse] = createSignal(false);
77
77
-
const [recreate, setRecreate] = createSignal(false);
78
78
-
const [openDelete, setOpenDelete] = createSignal(false);
79
79
-
const did = params.repo;
80
80
-
let pds: string;
81
81
-
let rpc: Client;
82
82
-
83
83
-
const fetchRecords = async () => {
84
84
-
if (!pds) pds = await resolvePDS(did);
85
85
-
if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: pds }) });
86
86
-
const res = await rpc.get("com.atproto.repo.listRecords", {
87
87
-
params: {
88
88
-
repo: did as ActorIdentifier,
89
89
-
collection: params.collection as `${string}.${string}.${string}`,
90
90
-
limit: LIMIT,
91
91
-
cursor: cursor(),
92
92
-
reverse: reverse(),
93
93
-
},
94
94
-
});
95
95
-
if (!res.ok) throw new Error(res.data.error);
96
96
-
setCursor(res.data.records.length < LIMIT ? undefined : res.data.cursor);
97
97
-
const tmpRecords: AtprotoRecord[] = [];
98
98
-
res.data.records.forEach((record) => {
99
99
-
const rkey = record.uri.split("/").pop()!;
100
100
-
tmpRecords.push({
101
101
-
rkey: rkey,
102
102
-
record: record,
103
103
-
timestamp: TID.validate(rkey) ? TID.parse(rkey).timestamp / 1000 : undefined,
104
104
-
toDelete: false,
105
105
-
});
106
106
-
});
107
107
-
setRecords(records.concat(tmpRecords) ?? tmpRecords);
108
108
-
return res.data.records;
109
109
-
};
110
110
-
111
111
-
const [response, { refetch }] = createResource(fetchRecords);
112
112
-
113
113
-
const deleteRecords = async () => {
114
114
-
const recsToDel = records.filter((record) => record.toDelete);
115
115
-
let writes: Array<
116
116
-
| $type.enforce<ComAtprotoRepoApplyWrites.Delete>
117
117
-
| $type.enforce<ComAtprotoRepoApplyWrites.Create>
118
118
-
> = [];
119
119
-
recsToDel.forEach((record) => {
120
120
-
writes.push({
121
121
-
$type: "com.atproto.repo.applyWrites#delete",
122
122
-
collection: params.collection as `${string}.${string}.${string}`,
123
123
-
rkey: record.rkey,
124
124
-
});
125
125
-
if (recreate()) {
126
126
-
writes.push({
127
127
-
$type: "com.atproto.repo.applyWrites#create",
128
128
-
collection: params.collection as `${string}.${string}.${string}`,
129
129
-
rkey: record.rkey,
130
130
-
value: record.record.value,
131
131
-
});
132
132
-
}
133
133
-
});
134
134
-
135
135
-
const BATCHSIZE = 200;
136
136
-
rpc = new Client({ handler: agent()! });
137
137
-
for (let i = 0; i < writes.length; i += BATCHSIZE) {
138
138
-
await rpc.post("com.atproto.repo.applyWrites", {
139
139
-
input: {
140
140
-
repo: agent()!.sub,
141
141
-
writes: writes.slice(i, i + BATCHSIZE),
142
142
-
},
143
143
-
});
144
144
-
}
145
145
-
setNotif({
146
146
-
show: true,
147
147
-
icon: "lucide--trash-2",
148
148
-
text: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`,
149
149
-
});
150
150
-
setBatchDelete(false);
151
151
-
setRecords([]);
152
152
-
setCursor(undefined);
153
153
-
setOpenDelete(false);
154
154
-
setRecreate(false);
155
155
-
refetch();
156
156
-
};
157
157
-
158
158
-
const handleSelectionClick = (e: MouseEvent, index: number) => {
159
159
-
if (e.shiftKey && lastSelected() !== undefined)
160
160
-
setRecords(
161
161
-
{
162
162
-
from: lastSelected()! < index ? lastSelected() : index + 1,
163
163
-
to: index > lastSelected()! ? index - 1 : lastSelected(),
164
164
-
},
165
165
-
"toDelete",
166
166
-
true,
167
167
-
);
168
168
-
else setLastSelected(index);
169
169
-
};
170
170
-
171
171
-
const selectAll = () =>
172
172
-
setRecords(
173
173
-
records
174
174
-
.map((record, index) =>
175
175
-
JSON.stringify(record.record.value).includes(filter() ?? "") ? index : undefined,
176
176
-
)
177
177
-
.filter((i) => i !== undefined),
178
178
-
"toDelete",
179
179
-
true,
180
180
-
);
181
181
-
182
182
-
return (
183
183
-
<Show when={records.length || response()}>
184
184
-
<div class="-mt-2 flex w-full flex-col items-center">
185
185
-
<StickyOverlay>
186
186
-
<div class="flex w-[22rem] items-center gap-1 sm:w-[24rem]">
187
187
-
<Show when={agent() && agent()?.sub === did}>
188
188
-
<div class="flex items-center">
189
189
-
<Tooltip
190
190
-
text={batchDelete() ? "Cancel" : "Delete"}
191
191
-
children={
192
192
-
<button
193
193
-
onclick={() => {
194
194
-
setRecords(
195
195
-
{ from: 0, to: untrack(() => records.length) - 1 },
196
196
-
"toDelete",
197
197
-
false,
198
198
-
);
199
199
-
setLastSelected(undefined);
200
200
-
setBatchDelete(!batchDelete());
201
201
-
}}
202
202
-
class="-ml-1 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"
203
203
-
>
204
204
-
<span
205
205
-
class={`iconify text-lg ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `}
206
206
-
></span>
207
207
-
</button>
208
208
-
}
209
209
-
/>
210
210
-
<Show when={batchDelete()}>
211
211
-
<Tooltip
212
212
-
text="Select all"
213
213
-
children={
214
214
-
<button
215
215
-
onclick={() => selectAll()}
216
216
-
class="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"
217
217
-
>
218
218
-
<span class="iconify lucide--copy-check text-lg"></span>
219
219
-
</button>
220
220
-
}
221
221
-
/>
222
222
-
<Tooltip
223
223
-
text="Recreate"
224
224
-
children={
225
225
-
<button
226
226
-
onclick={() => {
227
227
-
setRecreate(true);
228
228
-
setOpenDelete(true);
229
229
-
}}
230
230
-
class="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"
231
231
-
>
232
232
-
<span class="iconify lucide--recycle text-lg text-green-500 dark:text-green-400"></span>
233
233
-
</button>
234
234
-
}
235
235
-
/>
236
236
-
<Tooltip
237
237
-
text="Delete"
238
238
-
children={
239
239
-
<button
240
240
-
onclick={() => {
241
241
-
setRecreate(false);
242
242
-
setOpenDelete(true);
243
243
-
}}
244
244
-
class="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"
245
245
-
>
246
246
-
<span class="iconify lucide--trash-2 text-lg text-red-500 dark:text-red-400"></span>
247
247
-
</button>
248
248
-
}
249
249
-
/>
250
250
-
</Show>
251
251
-
</div>
252
252
-
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
253
253
-
<div class="dark:bg-dark-300 dark:shadow-dark-800 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-300 dark:border-neutral-700 starting:opacity-0">
254
254
-
<h2 class="mb-2 font-semibold">
255
255
-
{recreate() ? "Recreate" : "Delete"} {records.filter((r) => r.toDelete).length}{" "}
256
256
-
records?
257
257
-
</h2>
258
258
-
<div class="flex justify-end gap-2">
259
259
-
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
260
260
-
<Button
261
261
-
onClick={deleteRecords}
262
262
-
class={`dark:shadow-dark-800 rounded-lg px-2 py-1.5 text-xs font-semibold text-neutral-200 shadow-xs select-none ${recreate() ? "bg-green-500 hover:bg-green-400 dark:bg-green-600 dark:hover:bg-green-500" : "bg-red-500 hover:bg-red-400 active:bg-red-400"}`}
263
263
-
>
264
264
-
{recreate() ? "Recreate" : "Delete"}
265
265
-
</Button>
266
266
-
</div>
267
267
-
</div>
268
268
-
</Modal>
269
269
-
</Show>
270
270
-
<Tooltip text="Jetstream">
271
271
-
<A
272
272
-
href={`/jetstream?collections=${params.collection}&dids=${params.repo}`}
273
273
-
class="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"
274
274
-
>
275
275
-
<span class="iconify lucide--radio-tower text-lg"></span>
276
276
-
</A>
277
277
-
</Tooltip>
278
278
-
<TextInput
279
279
-
placeholder="Filter by substring"
280
280
-
onInput={(e) => setFilter(e.currentTarget.value)}
281
281
-
class="grow"
282
282
-
/>
283
283
-
</div>
284
284
-
<Show when={records.length > 1}>
285
285
-
<div class="flex w-[22rem] items-center justify-between gap-x-2 sm:w-[24rem]">
286
286
-
<Button
287
287
-
onClick={() => {
288
288
-
setReverse(!reverse());
289
289
-
setRecords([]);
290
290
-
setCursor(undefined);
291
291
-
refetch();
292
292
-
}}
293
293
-
>
294
294
-
<span
295
295
-
class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"} text-sm`}
296
296
-
></span>
297
297
-
Reverse
298
298
-
</Button>
299
299
-
<div>
300
300
-
<Show when={batchDelete()}>
301
301
-
<span>{records.filter((rec) => rec.toDelete).length}</span>
302
302
-
<span>/</span>
303
303
-
</Show>
304
304
-
<span>{records.length} records</span>
305
305
-
</div>
306
306
-
<div class="flex w-[5rem] items-center justify-end">
307
307
-
<Show when={cursor()}>
308
308
-
<Show when={!response.loading}>
309
309
-
<Button onClick={() => refetch()}>Load More</Button>
310
310
-
</Show>
311
311
-
<Show when={response.loading}>
312
312
-
<div class="iconify lucide--loader-circle w-[5rem] animate-spin text-xl" />
313
313
-
</Show>
314
314
-
</Show>
315
315
-
</div>
316
316
-
</div>
317
317
-
</Show>
318
318
-
</StickyOverlay>
319
319
-
<div class="flex max-w-full flex-col font-mono">
320
320
-
<For
321
321
-
each={records.filter((rec) =>
322
322
-
filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true,
323
323
-
)}
324
324
-
>
325
325
-
{(record, index) => (
326
326
-
<>
327
327
-
<Show when={batchDelete()}>
328
328
-
<label
329
329
-
class="flex items-center gap-1 select-none"
330
330
-
onclick={(e) => handleSelectionClick(e, index())}
331
331
-
>
332
332
-
<input
333
333
-
type="checkbox"
334
334
-
checked={record.toDelete}
335
335
-
onchange={(e) => setRecords(index(), "toDelete", e.currentTarget.checked)}
336
336
-
/>
337
337
-
<RecordLink record={record} />
338
338
-
</label>
339
339
-
</Show>
340
340
-
<Show when={!batchDelete()}>
341
341
-
<A href={`/at://${did}/${params.collection}/${record.rkey}`}>
342
342
-
<RecordLink record={record} />
343
343
-
</A>
344
344
-
</Show>
345
345
-
</>
346
346
-
)}
347
347
-
</For>
348
348
-
</div>
349
349
-
</div>
350
350
-
</Show>
351
351
-
);
352
352
-
};
353
353
-
354
354
-
export { CollectionView };
+71
src/views/enroll.tsx
···
1
1
+
import { createSignal, Show } from "solid-js";
2
2
+
import { useParams } from "@solidjs/router";
3
3
+
import { Button } from "../components/button.jsx";
4
4
+
import { TextInput } from "../components/text-input.jsx";
5
5
+
6
6
+
const EnrollView = () => {
7
7
+
const params = useParams();
8
8
+
const did = params.repo;
9
9
+
10
10
+
const [email, setEmail] = createSignal("");
11
11
+
const [submitted, setSubmitted] = createSignal(false);
12
12
+
const [submitting, setSubmitting] = createSignal(false);
13
13
+
const [error, setError] = createSignal<string>();
14
14
+
15
15
+
const handleSubmit = async (e: Event) => {
16
16
+
e.preventDefault();
17
17
+
setSubmitting(true);
18
18
+
setError(undefined);
19
19
+
20
20
+
console.log(`Enrolling ${email()} for DID ${did}`);
21
21
+
22
22
+
// This is where the call to the backend would go.
23
23
+
// For now, we'll just simulate a successful submission.
24
24
+
await new Promise((resolve) => setTimeout(resolve, 1000));
25
25
+
26
26
+
// Assuming the backend call is successful
27
27
+
setSubmitted(true);
28
28
+
setSubmitting(false);
29
29
+
};
30
30
+
31
31
+
return (
32
32
+
<div class="flex w-[22rem] flex-col gap-2 break-words sm:w-[24rem]">
33
33
+
<Show
34
34
+
when={!submitted()}
35
35
+
fallback={
36
36
+
<div class="rounded-lg bg-green-100 p-2 text-sm text-green-700 dark:bg-green-200 dark:text-green-600">
37
37
+
Activate your monitor by clicking the link in the confirmation email.
38
38
+
</div>
39
39
+
}
40
40
+
>
41
41
+
<form class="flex flex-col gap-2" onSubmit={handleSubmit}>
42
42
+
<p class="text-sm">
43
43
+
Get notified of changes to this DID document.
44
44
+
</p>
45
45
+
<TextInput
46
46
+
type="email"
47
47
+
placeholder="your@email.com"
48
48
+
value={email()}
49
49
+
onInput={(e) => setEmail(e.currentTarget.value)}
50
50
+
required
51
51
+
/>
52
52
+
<Button type="submit" disabled={submitting()}>
53
53
+
<Show when={submitting()} fallback={<>Start Monitoring</>}>
54
54
+
<div class="flex items-center gap-1">
55
55
+
<div class="iconify lucide--loader-circle animate-spin" />
56
56
+
Submitting...
57
57
+
</div>
58
58
+
</Show>
59
59
+
</Button>
60
60
+
<Show when={error()}>
61
61
+
<div class="rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-200 dark:text-red-600">
62
62
+
{error()}
63
63
+
</div>
64
64
+
</Show>
65
65
+
</form>
66
66
+
</Show>
67
67
+
</div>
68
68
+
);
69
69
+
};
70
70
+
71
71
+
export {EnrollView};
-181
src/views/labels.tsx
···
1
1
-
import { ComAtprotoLabelDefs } from "@atcute/atproto";
2
2
-
import { Client, CredentialManager } from "@atcute/client";
3
3
-
import { A, useParams, useSearchParams } from "@solidjs/router";
4
4
-
import { createResource, createSignal, For, onMount, Show } from "solid-js";
5
5
-
import { Button } from "../components/button.jsx";
6
6
-
import { StickyOverlay } from "../components/sticky.jsx";
7
7
-
import { TextInput } from "../components/text-input.jsx";
8
8
-
import { labelerCache, resolvePDS } from "../utils/api.js";
9
9
-
import { localDateFromTimestamp } from "../utils/date.js";
10
10
-
11
11
-
const LabelView = () => {
12
12
-
const params = useParams();
13
13
-
const [searchParams, setSearchParams] = useSearchParams();
14
14
-
const [cursor, setCursor] = createSignal<string>();
15
15
-
const [labels, setLabels] = createSignal<ComAtprotoLabelDefs.Label[]>([]);
16
16
-
const [filter, setFilter] = createSignal<string>();
17
17
-
const [labelCount, setLabelCount] = createSignal(0);
18
18
-
const did = params.repo;
19
19
-
let rpc: Client;
20
20
-
21
21
-
onMount(async () => {
22
22
-
await resolvePDS(did);
23
23
-
rpc = new Client({
24
24
-
handler: new CredentialManager({ service: labelerCache[did] }),
25
25
-
});
26
26
-
refetch();
27
27
-
});
28
28
-
29
29
-
const fetchLabels = async () => {
30
30
-
const uriPatterns = (document.getElementById("patterns") as HTMLInputElement).value;
31
31
-
if (!uriPatterns) return;
32
32
-
const res = await rpc.get("com.atproto.label.queryLabels", {
33
33
-
params: {
34
34
-
uriPatterns: uriPatterns.toString().trim().split(","),
35
35
-
sources: [did as `did:${string}:${string}`],
36
36
-
cursor: cursor(),
37
37
-
},
38
38
-
});
39
39
-
if (!res.ok) throw new Error(res.data.error);
40
40
-
setCursor(res.data.labels.length < 50 ? undefined : res.data.cursor);
41
41
-
setLabels(labels().concat(res.data.labels) ?? res.data.labels);
42
42
-
return res.data.labels;
43
43
-
};
44
44
-
45
45
-
const [response, { refetch }] = createResource(fetchLabels);
46
46
-
47
47
-
const initQuery = async () => {
48
48
-
setLabels([]);
49
49
-
setCursor("");
50
50
-
setSearchParams({
51
51
-
uriPatterns: (document.getElementById("patterns") as HTMLInputElement).value,
52
52
-
});
53
53
-
refetch();
54
54
-
};
55
55
-
56
56
-
const filterLabels = () => {
57
57
-
const newFilter = labels().filter((label) => (filter() ? filter() === label.val : true));
58
58
-
setLabelCount(newFilter.length);
59
59
-
return newFilter;
60
60
-
};
61
61
-
62
62
-
return (
63
63
-
<div class="flex w-full flex-col items-center">
64
64
-
<form
65
65
-
class="flex w-[22rem] flex-col items-center gap-y-1 sm:w-[24rem]"
66
66
-
onsubmit={(e) => {
67
67
-
e.preventDefault();
68
68
-
initQuery();
69
69
-
}}
70
70
-
>
71
71
-
<div class="w-full">
72
72
-
<label for="patterns" class="ml-0.5 text-sm">
73
73
-
URI Patterns (comma-separated)
74
74
-
</label>
75
75
-
</div>
76
76
-
<div class="flex w-full items-center gap-x-1">
77
77
-
<textarea
78
78
-
id="patterns"
79
79
-
name="patterns"
80
80
-
spellcheck={false}
81
81
-
rows={3}
82
82
-
value={searchParams.uriPatterns ?? "*"}
83
83
-
class="dark:bg-dark-100 dark:shadow-dark-800 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 shadow-xs focus:outline-[1.5px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200"
84
84
-
/>
85
85
-
<div class="flex justify-center">
86
86
-
<Show when={!response.loading}>
87
87
-
<button
88
88
-
type="submit"
89
89
-
class="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"
90
90
-
>
91
91
-
<span class="iconify lucide--search text-lg"></span>
92
92
-
</button>
93
93
-
</Show>
94
94
-
<Show when={response.loading}>
95
95
-
<div class="m-1 flex items-center">
96
96
-
<span class="iconify lucide--loader-circle animate-spin text-lg"></span>
97
97
-
</div>
98
98
-
</Show>
99
99
-
</div>
100
100
-
</div>
101
101
-
</form>
102
102
-
<StickyOverlay>
103
103
-
<TextInput
104
104
-
placeholder="Filter by label"
105
105
-
onInput={(e) => setFilter(e.currentTarget.value)}
106
106
-
class="w-[22rem] sm:w-[24rem]"
107
107
-
/>
108
108
-
<div class="flex items-center gap-x-2">
109
109
-
<Show when={labelCount() && labels().length}>
110
110
-
<div>
111
111
-
<span>
112
112
-
{labelCount()} label{labelCount() > 1 ? "s" : ""}
113
113
-
</span>
114
114
-
</div>
115
115
-
</Show>
116
116
-
<Show when={cursor()}>
117
117
-
<div class="flex h-[2rem] w-[5.5rem] items-center justify-center text-nowrap">
118
118
-
<Show when={!response.loading}>
119
119
-
<Button onClick={() => refetch()}>Load More</Button>
120
120
-
</Show>
121
121
-
<Show when={response.loading}>
122
122
-
<div class="iconify lucide--loader-circle animate-spin text-xl" />
123
123
-
</Show>
124
124
-
</div>
125
125
-
</Show>
126
126
-
</div>
127
127
-
</StickyOverlay>
128
128
-
<Show when={labels().length}>
129
129
-
<div class="flex max-w-full min-w-[22rem] flex-col gap-2 divide-y-[0.5px] divide-neutral-400 text-sm wrap-anywhere whitespace-pre-wrap sm:min-w-[24rem] dark:divide-neutral-600">
130
130
-
<For each={filterLabels()}>
131
131
-
{(label) => (
132
132
-
<div class="flex items-center justify-between gap-2 pb-2">
133
133
-
<div class="flex flex-col">
134
134
-
<div class="flex items-center gap-x-2">
135
135
-
<div class="min-w-[5rem] font-semibold">URI</div>
136
136
-
<A
137
137
-
href={`/at://${label.uri.replace("at://", "")}`}
138
138
-
class="text-blue-400 hover:underline active:underline"
139
139
-
>
140
140
-
{label.uri}
141
141
-
</A>
142
142
-
</div>
143
143
-
<Show when={label.cid}>
144
144
-
<div class="flex items-center gap-x-2">
145
145
-
<div class="min-w-[5rem] font-semibold">CID</div>
146
146
-
{label.cid}
147
147
-
</div>
148
148
-
</Show>
149
149
-
<div class="flex items-center gap-x-2">
150
150
-
<div class="min-w-[5rem] font-semibold">Label</div>
151
151
-
{label.val}
152
152
-
</div>
153
153
-
<div class="flex items-center gap-x-2">
154
154
-
<div class="min-w-[5rem] font-semibold">Created</div>
155
155
-
{localDateFromTimestamp(new Date(label.cts).getTime())}
156
156
-
</div>
157
157
-
<Show when={label.exp}>
158
158
-
{(exp) => (
159
159
-
<div class="flex items-center gap-x-2">
160
160
-
<div class="min-w-[5rem] font-semibold">Expires</div>
161
161
-
{localDateFromTimestamp(new Date(exp()).getTime())}
162
162
-
</div>
163
163
-
)}
164
164
-
</Show>
165
165
-
</div>
166
166
-
<Show when={label.neg}>
167
167
-
<div class="iconify lucide--minus shrink-0 text-lg text-red-500 dark:text-red-400" />
168
168
-
</Show>
169
169
-
</div>
170
170
-
)}
171
171
-
</For>
172
172
-
</div>
173
173
-
</Show>
174
174
-
<Show when={!labels().length && !response.loading && searchParams.uriPatterns}>
175
175
-
<div class="mt-2">No results</div>
176
176
-
</Show>
177
177
-
</div>
178
178
-
);
179
179
-
};
180
180
-
181
181
-
export { LabelView };
-120
src/views/pds.tsx
···
1
1
-
import { ComAtprotoServerDescribeServer, ComAtprotoSyncListRepos } from "@atcute/atproto";
2
2
-
import { Client, CredentialManager } from "@atcute/client";
3
3
-
import { InferXRPCBodyOutput } from "@atcute/lexicons";
4
4
-
import * as TID from "@atcute/tid";
5
5
-
import { A, useParams } from "@solidjs/router";
6
6
-
import { createResource, createSignal, For, Show } from "solid-js";
7
7
-
import { Button } from "../components/button";
8
8
-
import { setPDS } from "../components/navbar";
9
9
-
import Tooltip from "../components/tooltip";
10
10
-
import { localDateFromTimestamp } from "../utils/date";
11
11
-
12
12
-
const LIMIT = 1000;
13
13
-
14
14
-
const PdsView = () => {
15
15
-
const params = useParams();
16
16
-
const [version, setVersion] = createSignal<string>();
17
17
-
const [serverInfos, setServerInfos] =
18
18
-
createSignal<InferXRPCBodyOutput<ComAtprotoServerDescribeServer.mainSchema["output"]>>();
19
19
-
const [cursor, setCursor] = createSignal<string>();
20
20
-
setPDS(params.pds);
21
21
-
const pds = params.pds.startsWith("localhost") ? `http://${params.pds}` : `https://${params.pds}`;
22
22
-
const rpc = new Client({ handler: new CredentialManager({ service: pds }) });
23
23
-
24
24
-
const getVersion = async () => {
25
25
-
// @ts-expect-error: undocumented endpoint
26
26
-
const res = await rpc.get("_health", {});
27
27
-
setVersion((res.data as any).version);
28
28
-
};
29
29
-
30
30
-
const fetchRepos = async () => {
31
31
-
getVersion();
32
32
-
const describeRes = await rpc.get("com.atproto.server.describeServer");
33
33
-
if (!describeRes.ok) console.error(describeRes.data.error);
34
34
-
else setServerInfos(describeRes.data);
35
35
-
const res = await rpc.get("com.atproto.sync.listRepos", {
36
36
-
params: { limit: LIMIT, cursor: cursor() },
37
37
-
});
38
38
-
if (!res.ok) throw new Error(res.data.error);
39
39
-
setCursor(res.data.repos.length < LIMIT ? undefined : res.data.cursor);
40
40
-
setRepos(repos()?.concat(res.data.repos) ?? res.data.repos);
41
41
-
return res.data;
42
42
-
};
43
43
-
44
44
-
const [response, { refetch }] = createResource(fetchRepos);
45
45
-
const [repos, setRepos] = createSignal<ComAtprotoSyncListRepos.Repo[]>();
46
46
-
47
47
-
return (
48
48
-
<Show when={repos() || response()}>
49
49
-
<div class="flex w-[22rem] flex-col sm:w-[24rem]">
50
50
-
<Show when={version()}>
51
51
-
{(version) => (
52
52
-
<div class="flex items-baseline gap-x-1">
53
53
-
<span class="font-semibold">Version</span>
54
54
-
<span class="truncate text-sm">{version()}</span>
55
55
-
</div>
56
56
-
)}
57
57
-
</Show>
58
58
-
<Show when={serverInfos()}>
59
59
-
{(server) => (
60
60
-
<>
61
61
-
<Show when={server().inviteCodeRequired}>
62
62
-
<span class="font-semibold">Invite Code Required</span>
63
63
-
</Show>
64
64
-
<Show when={server().phoneVerificationRequired}>
65
65
-
<span class="font-semibold">Phone Verification Required</span>
66
66
-
</Show>
67
67
-
<Show when={server().availableUserDomains.length}>
68
68
-
<div class="flex flex-col">
69
69
-
<span class="font-semibold">Available User Domains</span>
70
70
-
<For each={server().availableUserDomains}>
71
71
-
{(domain) => <span class="text-sm wrap-anywhere">{domain}</span>}
72
72
-
</For>
73
73
-
</div>
74
74
-
</Show>
75
75
-
</>
76
76
-
)}
77
77
-
</Show>
78
78
-
<p class="w-full font-semibold">{repos()?.length} Repositories</p>
79
79
-
<For each={repos()}>
80
80
-
{(repo) => (
81
81
-
<A
82
82
-
href={`/at://${repo.did}`}
83
83
-
classList={{
84
84
-
"rounded items-center text-sm gap-1 flex justify-between font-mono relative hover:bg-neutral-200 dark:hover:bg-neutral-700 active:bg-neutral-300 dark:active:bg-neutral-600": true,
85
85
-
"text-blue-400": repo.active,
86
86
-
"text-neutral-400 dark:text-neutral-500": !repo.active,
87
87
-
}}
88
88
-
>
89
89
-
<Show when={!repo.active}>
90
90
-
<div class="absolute -left-4">
91
91
-
<Tooltip text={repo.status ?? "Unknown status"}>
92
92
-
<span class="iconify lucide--unplug"></span>
93
93
-
</Tooltip>
94
94
-
</div>
95
95
-
</Show>
96
96
-
<span class="text-sm">{repo.did}</span>
97
97
-
<Show when={TID.validate(repo.rev)}>
98
98
-
<span class="text-xs text-neutral-500 dark:text-neutral-400">
99
99
-
{localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000).split(" ")[0]}
100
100
-
</span>
101
101
-
</Show>
102
102
-
</A>
103
103
-
)}
104
104
-
</For>
105
105
-
</div>
106
106
-
<Show when={cursor()}>
107
107
-
<div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-3">
108
108
-
<Show when={!response.loading}>
109
109
-
<Button onClick={() => refetch()}>Load More</Button>
110
110
-
</Show>
111
111
-
<Show when={response.loading}>
112
112
-
<span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span>
113
113
-
</Show>
114
114
-
</div>
115
115
-
</Show>
116
116
-
</Show>
117
117
-
);
118
118
-
};
119
119
-
120
120
-
export { PdsView };
-227
src/views/record.tsx
···
1
1
-
import { Client, CredentialManager } from "@atcute/client";
2
2
-
import { lexiconDoc } from "@atcute/lexicon-doc";
3
3
-
import { ActorIdentifier, is, ResourceUri } from "@atcute/lexicons";
4
4
-
import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
5
5
-
import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js";
6
6
-
import { Backlinks } from "../components/backlinks.jsx";
7
7
-
import { Button } from "../components/button.jsx";
8
8
-
import { RecordEditor } from "../components/create.jsx";
9
9
-
import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx";
10
10
-
import { JSONValue } from "../components/json.jsx";
11
11
-
import { agent } from "../components/login.jsx";
12
12
-
import { Modal } from "../components/modal.jsx";
13
13
-
import { pds, setCID, setValidRecord, setValidSchema, validRecord } from "../components/navbar.jsx";
14
14
-
import Tooltip from "../components/tooltip.jsx";
15
15
-
import { setNotif } from "../layout.jsx";
16
16
-
import { didDocCache, resolvePDS } from "../utils/api.js";
17
17
-
import { AtUri, uriTemplates } from "../utils/templates.js";
18
18
-
import { lexicons } from "../utils/types/lexicons.js";
19
19
-
import { verifyRecord } from "../utils/verify.js";
20
20
-
21
21
-
export const RecordView = () => {
22
22
-
const location = useLocation();
23
23
-
const navigate = useNavigate();
24
24
-
const params = useParams();
25
25
-
const [openDelete, setOpenDelete] = createSignal(false);
26
26
-
const [notice, setNotice] = createSignal("");
27
27
-
const [externalLink, setExternalLink] = createSignal<
28
28
-
{ label: string; link: string; icon?: string } | undefined
29
29
-
>();
30
30
-
const did = params.repo;
31
31
-
let rpc: Client;
32
32
-
33
33
-
const fetchRecord = async () => {
34
34
-
setCID(undefined);
35
35
-
setValidRecord(undefined);
36
36
-
setValidSchema(undefined);
37
37
-
const pds = await resolvePDS(did);
38
38
-
rpc = new Client({ handler: new CredentialManager({ service: pds }) });
39
39
-
const res = await rpc.get("com.atproto.repo.getRecord", {
40
40
-
params: {
41
41
-
repo: did as ActorIdentifier,
42
42
-
collection: params.collection as `${string}.${string}.${string}`,
43
43
-
rkey: params.rkey,
44
44
-
},
45
45
-
});
46
46
-
if (!res.ok) {
47
47
-
setValidRecord(false);
48
48
-
setNotice(res.data.error);
49
49
-
throw new Error(res.data.error);
50
50
-
}
51
51
-
setCID(res.data.cid);
52
52
-
setExternalLink(checkUri(res.data.uri, res.data.value));
53
53
-
verify(res.data);
54
54
-
55
55
-
return res.data;
56
56
-
};
57
57
-
58
58
-
const verify = async (record: {
59
59
-
uri: ResourceUri;
60
60
-
value: Record<string, unknown>;
61
61
-
cid?: string | undefined;
62
62
-
}) => {
63
63
-
try {
64
64
-
if (params.collection in lexicons) {
65
65
-
if (is(lexicons[params.collection], record.value)) setValidSchema(true);
66
66
-
else setValidSchema(false);
67
67
-
} else if (params.collection === "com.atproto.lexicon.schema") {
68
68
-
try {
69
69
-
lexiconDoc.parse(record.value, { mode: "passthrough" });
70
70
-
setValidSchema(true);
71
71
-
} catch (e) {
72
72
-
console.error(e);
73
73
-
setValidSchema(false);
74
74
-
}
75
75
-
}
76
76
-
const { errors } = await verifyRecord({
77
77
-
rpc: rpc,
78
78
-
uri: record.uri,
79
79
-
cid: record.cid!,
80
80
-
record: record.value,
81
81
-
didDoc: didDocCache[record.uri.split("/")[2]],
82
82
-
});
83
83
-
84
84
-
if (errors.length > 0) {
85
85
-
console.warn(errors);
86
86
-
setNotice(`Invalid record: ${errors.map((e) => e.message).join("\n")}`);
87
87
-
}
88
88
-
setValidRecord(errors.length === 0);
89
89
-
} catch (err) {
90
90
-
console.error(err);
91
91
-
setValidRecord(false);
92
92
-
}
93
93
-
};
94
94
-
95
95
-
const [record, { refetch }] = createResource(fetchRecord);
96
96
-
97
97
-
const deleteRecord = async () => {
98
98
-
rpc = new Client({ handler: agent()! });
99
99
-
await rpc.post("com.atproto.repo.deleteRecord", {
100
100
-
input: {
101
101
-
repo: params.repo as ActorIdentifier,
102
102
-
collection: params.collection as `${string}.${string}.${string}`,
103
103
-
rkey: params.rkey,
104
104
-
},
105
105
-
});
106
106
-
setNotif({ show: true, icon: "lucide--trash-2", text: "Record deleted" });
107
107
-
navigate(`/at://${params.repo}/${params.collection}`);
108
108
-
};
109
109
-
110
110
-
const checkUri = (uri: string, record: any) => {
111
111
-
const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"]
112
112
-
if (uriParts.length != 5) return undefined;
113
113
-
if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined;
114
114
-
const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] };
115
115
-
const template = uriTemplates[parsedUri.collection];
116
116
-
if (!template) return undefined;
117
117
-
return template(parsedUri, record);
118
118
-
};
119
119
-
120
120
-
return (
121
121
-
<Show when={record()} keyed>
122
122
-
<div class="flex w-full flex-col items-center">
123
123
-
<div class="dark:shadow-dark-800 dark:bg-dark-300 mb-3 flex w-[22rem] justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 shadow-xs sm:w-[24rem] dark:border-neutral-700">
124
124
-
<div class="flex gap-3 text-sm">
125
125
-
<A
126
126
-
classList={{
127
127
-
"flex items-center gap-1 border-b-2": true,
128
128
-
"border-transparent hover:border-neutral-400 dark:hover:border-neutral-600":
129
129
-
!!location.hash && location.hash !== "#record",
130
130
-
}}
131
131
-
href={`/at://${did}/${params.collection}/${params.rkey}#record`}
132
132
-
>
133
133
-
<div class="iconify lucide--file-json" />
134
134
-
Record
135
135
-
</A>
136
136
-
<A
137
137
-
classList={{
138
138
-
"flex items-center gap-1 border-b-2": true,
139
139
-
"border-transparent hover:border-neutral-400 dark:hover:border-neutral-600":
140
140
-
location.hash !== "#backlinks",
141
141
-
}}
142
142
-
href={`/at://${did}/${params.collection}/${params.rkey}#backlinks`}
143
143
-
>
144
144
-
<div class="iconify lucide--send-to-back" />
145
145
-
Backlinks
146
146
-
</A>
147
147
-
</div>
148
148
-
<div class="flex gap-1">
149
149
-
<Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
150
150
-
<RecordEditor create={false} record={record()?.value} refetch={refetch} />
151
151
-
<Tooltip text="Delete">
152
152
-
<button
153
153
-
class="flex items-center rounded-sm p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
154
154
-
onclick={() => setOpenDelete(true)}
155
155
-
>
156
156
-
<span class="iconify lucide--trash-2"></span>
157
157
-
</button>
158
158
-
</Tooltip>
159
159
-
<Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
160
160
-
<div class="dark:bg-dark-300 dark:shadow-dark-800 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-300 dark:border-neutral-700 starting:opacity-0">
161
161
-
<h2 class="mb-2 font-semibold">Delete this record?</h2>
162
162
-
<div class="flex justify-end gap-2">
163
163
-
<Button onClick={() => setOpenDelete(false)}>Cancel</Button>
164
164
-
<Button
165
165
-
onClick={deleteRecord}
166
166
-
class="dark:shadow-dark-800 rounded-lg bg-red-500 px-2 py-1.5 text-xs font-semibold text-neutral-200 shadow-xs select-none hover:bg-red-400 active:bg-red-400"
167
167
-
>
168
168
-
Delete
169
169
-
</Button>
170
170
-
</div>
171
171
-
</div>
172
172
-
</Modal>
173
173
-
</Show>
174
174
-
<MenuProvider>
175
175
-
<DropdownMenu
176
176
-
icon="lucide--ellipsis-vertical "
177
177
-
buttonClass="rounded-sm p-1"
178
178
-
menuClass="top-8 p-2 text-sm"
179
179
-
>
180
180
-
<CopyMenu
181
181
-
copyContent={JSON.stringify(record()?.value, null, 2)}
182
182
-
label="Copy record"
183
183
-
icon="lucide--copy"
184
184
-
/>
185
185
-
<Show when={externalLink()}>
186
186
-
{(externalLink) => (
187
187
-
<NavMenu
188
188
-
href={externalLink()?.link}
189
189
-
icon={`${externalLink().icon ?? "lucide--app-window"}`}
190
190
-
label={`Open on ${externalLink().label}`}
191
191
-
newTab
192
192
-
/>
193
193
-
)}
194
194
-
</Show>
195
195
-
<NavMenu
196
196
-
href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`}
197
197
-
icon="lucide--external-link"
198
198
-
label="Record on PDS"
199
199
-
newTab
200
200
-
/>
201
201
-
</DropdownMenu>
202
202
-
</MenuProvider>
203
203
-
</div>
204
204
-
</div>
205
205
-
<Show when={!location.hash || location.hash === "#record"}>
206
206
-
<Show when={validRecord() === false}>
207
207
-
<div class="mb-2 break-words text-red-500 dark:text-red-400">{notice()}</div>
208
208
-
</Show>
209
209
-
<div class="w-[22rem] font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:w-full sm:text-sm">
210
210
-
<JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} />
211
211
-
</div>
212
212
-
</Show>
213
213
-
<Show when={location.hash === "#backlinks"}>
214
214
-
<ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}>
215
215
-
<Suspense
216
216
-
fallback={
217
217
-
<div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
218
218
-
}
219
219
-
>
220
220
-
<Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} />
221
221
-
</Suspense>
222
222
-
</ErrorBoundary>
223
223
-
</Show>
224
224
-
</div>
225
225
-
</Show>
226
226
-
);
227
227
-
};
-537
src/views/repo.tsx
···
1
1
-
import { Client, CredentialManager } from "@atcute/client";
2
2
-
import { parsePublicMultikey } from "@atcute/crypto";
3
3
-
import {
4
4
-
CompatibleOperationOrTombstone,
5
5
-
defs,
6
6
-
IndexedEntry,
7
7
-
processIndexedEntryLog,
8
8
-
} from "@atcute/did-plc";
9
9
-
import { DidDocument } from "@atcute/identity";
10
10
-
import { ActorIdentifier, Handle } from "@atcute/lexicons";
11
11
-
import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
12
12
-
import { createResource, createSignal, ErrorBoundary, For, Show, Suspense } from "solid-js";
13
13
-
import { Backlinks } from "../components/backlinks.jsx";
14
14
-
import { Button } from "../components/button.jsx";
15
15
-
import { TextInput } from "../components/text-input.jsx";
16
16
-
import Tooltip from "../components/tooltip.jsx";
17
17
-
import { didDocCache, resolveHandle, resolvePDS } from "../utils/api.js";
18
18
-
import { localDateFromTimestamp } from "../utils/date.js";
19
19
-
import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js";
20
20
-
import { BlobView } from "./blob.jsx";
21
21
-
22
22
-
type Tab = "collections" | "backlinks" | "identity" | "blobs";
23
23
-
type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method";
24
24
-
25
25
-
const PlcLogView = (props: {
26
26
-
did: string;
27
27
-
plcOps: [IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][];
28
28
-
}) => {
29
29
-
const [activePlcEvent, setActivePlcEvent] = createSignal<PlcEvent | undefined>();
30
30
-
31
31
-
const FilterButton = (props: { icon: string; event: PlcEvent }) => (
32
32
-
<button
33
33
-
classList={{
34
34
-
"flex items-center rounded-full p-1.5": true,
35
35
-
"bg-neutral-700 dark:bg-neutral-200": activePlcEvent() === props.event,
36
36
-
}}
37
37
-
onclick={() => setActivePlcEvent(activePlcEvent() === props.event ? undefined : props.event)}
38
38
-
>
39
39
-
<span
40
40
-
class={`${props.icon} ${activePlcEvent() === props.event ? "text-neutral-200 dark:text-neutral-900" : ""}`}
41
41
-
></span>
42
42
-
</button>
43
43
-
);
44
44
-
45
45
-
const DiffItem = (props: { diff: DiffEntry }) => {
46
46
-
const diff = props.diff;
47
47
-
let title = "Unknown log entry";
48
48
-
let icon = "lucide--circle-help";
49
49
-
let value = "";
50
50
-
51
51
-
if (diff.type === "identity_created") {
52
52
-
icon = "lucide--bell";
53
53
-
title = `Identity created`;
54
54
-
} else if (diff.type === "identity_tombstoned") {
55
55
-
icon = "lucide--skull";
56
56
-
title = `Identity tombstoned`;
57
57
-
} else if (diff.type === "handle_added" || diff.type === "handle_removed") {
58
58
-
icon = "lucide--at-sign";
59
59
-
title = diff.type === "handle_added" ? "Alias added" : "Alias removed";
60
60
-
value = diff.handle;
61
61
-
} else if (diff.type === "handle_changed") {
62
62
-
icon = "lucide--at-sign";
63
63
-
title = "Alias updated";
64
64
-
value = `${diff.prev_handle} โ ${diff.next_handle}`;
65
65
-
} else if (diff.type === "rotation_key_added" || diff.type === "rotation_key_removed") {
66
66
-
icon = "lucide--key-round";
67
67
-
title = diff.type === "rotation_key_added" ? "Rotation key added" : "Rotation key removed";
68
68
-
value = diff.rotation_key;
69
69
-
} else if (diff.type === "service_added" || diff.type === "service_removed") {
70
70
-
icon = "lucide--hard-drive";
71
71
-
title = `Service ${diff.service_id} ${diff.type === "service_added" ? "added" : "removed"}`;
72
72
-
value = `${diff.service_endpoint}`;
73
73
-
} else if (diff.type === "service_changed") {
74
74
-
icon = "lucide--hard-drive";
75
75
-
title = `Service ${diff.service_id} updated`;
76
76
-
value = `${diff.prev_service_endpoint} โ ${diff.next_service_endpoint}`;
77
77
-
} else if (
78
78
-
diff.type === "verification_method_added" ||
79
79
-
diff.type === "verification_method_removed"
80
80
-
) {
81
81
-
icon = "lucide--shield-check";
82
82
-
title = `Verification method ${diff.method_id} ${diff.type === "verification_method_added" ? "added" : "removed"}`;
83
83
-
value = `${diff.method_key}`;
84
84
-
} else if (diff.type === "verification_method_changed") {
85
85
-
icon = "lucide--shield-check";
86
86
-
title = `Verification method ${diff.method_id} updated`;
87
87
-
value = `${diff.prev_method_key} โ ${diff.next_method_key}`;
88
88
-
}
89
89
-
90
90
-
return (
91
91
-
<div class="grid grid-cols-[min-content_1fr] items-center gap-x-1">
92
92
-
<div class={icon + ` iconify shrink-0`} />
93
93
-
<p
94
94
-
classList={{
95
95
-
"font-semibold": true,
96
96
-
"text-neutral-400 line-through dark:text-neutral-600": diff.orig.nullified,
97
97
-
}}
98
98
-
>
99
99
-
{title}
100
100
-
</p>
101
101
-
<div></div>
102
102
-
{value}
103
103
-
</div>
104
104
-
);
105
105
-
};
106
106
-
107
107
-
return (
108
108
-
<>
109
109
-
<div class="flex items-center justify-between">
110
110
-
<div class="flex items-center gap-1">
111
111
-
<div class="iconify lucide--filter" />
112
112
-
<div class="dark:shadow-dark-800 dark:bg-dark-300 flex w-fit items-center rounded-full border-[0.5px] border-neutral-300 bg-neutral-50 shadow-xs dark:border-neutral-700">
113
113
-
<FilterButton icon="iconify lucide--at-sign" event="handle" />
114
114
-
<FilterButton icon="iconify lucide--key-round" event="rotation_key" />
115
115
-
<FilterButton icon="iconify lucide--hard-drive" event="service" />
116
116
-
<FilterButton icon="iconify lucide--shield-check" event="verification_method" />
117
117
-
</div>
118
118
-
</div>
119
119
-
<Tooltip text="Audit log">
120
120
-
<a
121
121
-
href={`${localStorage.plcDirectory ?? "https://plc.directory"}/${props.did}/log/audit`}
122
122
-
target="_blank"
123
123
-
class="-mr-1 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"
124
124
-
>
125
125
-
<span class="iconify lucide--external-link"></span>
126
126
-
</a>
127
127
-
</Tooltip>
128
128
-
</div>
129
129
-
<div class="flex flex-col gap-1 text-sm">
130
130
-
<For each={props.plcOps}>
131
131
-
{([entry, diffs]) => (
132
132
-
<Show
133
133
-
when={!activePlcEvent() || diffs.find((d) => d.type.startsWith(activePlcEvent()!))}
134
134
-
>
135
135
-
<div class="flex flex-col">
136
136
-
<span class="text-neutral-500 dark:text-neutral-400">
137
137
-
{localDateFromTimestamp(new Date(entry.createdAt).getTime())}
138
138
-
</span>
139
139
-
{diffs.map((diff) => (
140
140
-
<Show when={!activePlcEvent() || diff.type.startsWith(activePlcEvent()!)}>
141
141
-
<DiffItem diff={diff} />
142
142
-
</Show>
143
143
-
))}
144
144
-
</div>
145
145
-
</Show>
146
146
-
)}
147
147
-
</For>
148
148
-
</div>
149
149
-
</>
150
150
-
);
151
151
-
};
152
152
-
153
153
-
const RepoView = () => {
154
154
-
const params = useParams();
155
155
-
const location = useLocation();
156
156
-
const navigate = useNavigate();
157
157
-
const [error, setError] = createSignal<string>();
158
158
-
const [downloading, setDownloading] = createSignal(false);
159
159
-
const [didDoc, setDidDoc] = createSignal<DidDocument>();
160
160
-
const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>();
161
161
-
const [filter, setFilter] = createSignal<string>();
162
162
-
const [plcOps, setPlcOps] =
163
163
-
createSignal<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>();
164
164
-
const [showPlcLogs, setShowPlcLogs] = createSignal(false);
165
165
-
const [loading, setLoading] = createSignal(false);
166
166
-
const [notice, setNotice] = createSignal<string>();
167
167
-
let rpc: Client;
168
168
-
let pds: string;
169
169
-
const did = params.repo;
170
170
-
171
171
-
const RepoTab = (props: { tab: Tab; label: string; icon: string }) => (
172
172
-
<A
173
173
-
classList={{
174
174
-
"flex items-center border-b-2 gap-1": true,
175
175
-
"border-transparent hover:border-neutral-400 dark:hover:border-neutral-600":
176
176
-
(location.hash !== `#${props.tab}` && !!location.hash) ||
177
177
-
(!location.hash && props.tab !== "collections"),
178
178
-
}}
179
179
-
href={`/at://${params.repo}#${props.tab}`}
180
180
-
>
181
181
-
<div class={"iconify " + props.icon} />
182
182
-
{props.label}
183
183
-
</A>
184
184
-
);
185
185
-
186
186
-
const fetchRepo = async () => {
187
187
-
try {
188
188
-
pds = await resolvePDS(did);
189
189
-
} catch {
190
190
-
try {
191
191
-
const did = await resolveHandle(params.repo as Handle);
192
192
-
navigate(location.pathname.replace(params.repo, did));
193
193
-
} catch {
194
194
-
navigate(`/${did}`);
195
195
-
}
196
196
-
}
197
197
-
setDidDoc(didDocCache[did] as DidDocument);
198
198
-
199
199
-
rpc = new Client({ handler: new CredentialManager({ service: pds }) });
200
200
-
const res = await rpc.get("com.atproto.repo.describeRepo", {
201
201
-
params: { repo: did as ActorIdentifier },
202
202
-
});
203
203
-
if (res.ok) {
204
204
-
const collections: Record<string, { hidden: boolean; nsids: string[] }> = {};
205
205
-
res.data.collections.forEach((c) => {
206
206
-
const nsid = c.split(".");
207
207
-
if (nsid.length > 2) {
208
208
-
const authority = `${nsid[0]}.${nsid[1]}`;
209
209
-
collections[authority] = {
210
210
-
nsids: (collections[authority]?.nsids ?? []).concat(nsid.slice(2).join(".")),
211
211
-
hidden: false,
212
212
-
};
213
213
-
}
214
214
-
});
215
215
-
setNsids(collections);
216
216
-
} else {
217
217
-
console.error(res.data.error);
218
218
-
switch (res.data.error) {
219
219
-
case "RepoDeactivated":
220
220
-
setError("This repository has been deactivated");
221
221
-
break;
222
222
-
case "RepoTakendown":
223
223
-
setError("This repository has been taken down");
224
224
-
break;
225
225
-
default:
226
226
-
setError("This repository is unreachable");
227
227
-
}
228
228
-
navigate(`/at://${params.repo}#identity`);
229
229
-
}
230
230
-
231
231
-
return res.data;
232
232
-
};
233
233
-
234
234
-
const [repo] = createResource(fetchRepo);
235
235
-
236
236
-
const downloadRepo = async () => {
237
237
-
try {
238
238
-
setDownloading(true);
239
239
-
const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`);
240
240
-
if (!response.ok) {
241
241
-
throw new Error(`HTTP error status: ${response.status}`);
242
242
-
}
243
243
-
244
244
-
const blob = await response.blob();
245
245
-
const url = window.URL.createObjectURL(blob);
246
246
-
const a = document.createElement("a");
247
247
-
a.href = url;
248
248
-
a.download = `${did}-${new Date().toISOString()}.car`;
249
249
-
document.body.appendChild(a);
250
250
-
a.click();
251
251
-
252
252
-
window.URL.revokeObjectURL(url);
253
253
-
document.body.removeChild(a);
254
254
-
} catch (error) {
255
255
-
console.error("Download failed:", error);
256
256
-
}
257
257
-
setDownloading(false);
258
258
-
};
259
259
-
260
260
-
const toggleCollection = (authority: string) => {
261
261
-
setNsids({
262
262
-
...nsids(),
263
263
-
[authority]: { ...nsids()![authority], hidden: !nsids()![authority].hidden },
264
264
-
});
265
265
-
};
266
266
-
267
267
-
return (
268
268
-
<Show when={repo()}>
269
269
-
<div class="flex w-[22rem] flex-col gap-2 break-words sm:w-[24rem]">
270
270
-
<Show when={error()}>
271
271
-
<div class="rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-200 dark:text-red-600">
272
272
-
{error()}
273
273
-
</div>
274
274
-
</Show>
275
275
-
<div
276
276
-
class={`dark:shadow-dark-800 dark:bg-dark-300 flex ${error() ? "justify-around" : "justify-between"} rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm shadow-xs dark:border-neutral-700`}
277
277
-
>
278
278
-
<Show when={!error()}>
279
279
-
<RepoTab tab="collections" label="Collections" icon="lucide--folder-open" />
280
280
-
</Show>
281
281
-
<RepoTab tab="identity" label="Identity" icon="lucide--id-card" />
282
282
-
<Show when={!error()}>
283
283
-
<RepoTab tab="blobs" label="Blobs" icon="lucide--file-digit" />
284
284
-
</Show>
285
285
-
<RepoTab tab="backlinks" label="Backlinks" icon="lucide--send-to-back" />
286
286
-
</div>
287
287
-
<Show when={location.hash === "#backlinks"}>
288
288
-
<ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}>
289
289
-
<Suspense
290
290
-
fallback={
291
291
-
<div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
292
292
-
}
293
293
-
>
294
294
-
<Backlinks target={did} />
295
295
-
</Suspense>
296
296
-
</ErrorBoundary>
297
297
-
</Show>
298
298
-
<Show when={location.hash === "#blobs"}>
299
299
-
<ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}>
300
300
-
<Suspense
301
301
-
fallback={
302
302
-
<div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
303
303
-
}
304
304
-
>
305
305
-
<BlobView pds={pds!} repo={did} />
306
306
-
</Suspense>
307
307
-
</ErrorBoundary>
308
308
-
</Show>
309
309
-
<Show when={nsids() && (!location.hash || location.hash === "#collections")}>
310
310
-
<div class="flex items-center gap-1">
311
311
-
<Tooltip text="Jetstream">
312
312
-
<A
313
313
-
href={`/jetstream?dids=${params.repo}`}
314
314
-
class="-ml-1 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"
315
315
-
>
316
316
-
<span class="iconify lucide--radio-tower text-lg"></span>
317
317
-
</A>
318
318
-
</Tooltip>
319
319
-
<TextInput
320
320
-
placeholder="Filter collections"
321
321
-
onInput={(e) => setFilter(e.currentTarget.value)}
322
322
-
class="grow"
323
323
-
/>
324
324
-
</div>
325
325
-
<div class="flex flex-col font-mono">
326
326
-
<div class="grid grid-cols-[min-content_1fr] items-center gap-x-2 overflow-hidden text-sm">
327
327
-
<For
328
328
-
each={Object.keys(nsids() ?? {}).filter((authority) =>
329
329
-
filter() ?
330
330
-
authority.startsWith(filter()!) || filter()?.startsWith(authority)
331
331
-
: true,
332
332
-
)}
333
333
-
>
334
334
-
{(authority) => (
335
335
-
<>
336
336
-
<button onclick={() => toggleCollection(authority)} class="flex items-center">
337
337
-
<span
338
338
-
classList={{
339
339
-
"iconify lucide--chevron-down text-lg transition-transform": true,
340
340
-
"-rotate-90": nsids()?.[authority].hidden,
341
341
-
}}
342
342
-
></span>
343
343
-
</button>
344
344
-
<button
345
345
-
class="bg-transparent text-left wrap-anywhere"
346
346
-
onclick={() => toggleCollection(authority)}
347
347
-
>
348
348
-
{authority}
349
349
-
</button>
350
350
-
<Show when={!nsids()?.[authority].hidden}>
351
351
-
<div></div>
352
352
-
<div class="flex flex-col">
353
353
-
<For
354
354
-
each={nsids()?.[authority].nsids.filter((nsid) =>
355
355
-
filter() ?
356
356
-
nsid.startsWith(filter()!.split(".").slice(2).join("."))
357
357
-
: true,
358
358
-
)}
359
359
-
>
360
360
-
{(nsid) => (
361
361
-
<A
362
362
-
href={`/at://${did}/${authority}.${nsid}`}
363
363
-
class="text-blue-400 hover:underline active:underline"
364
364
-
>
365
365
-
{authority}.{nsid}
366
366
-
</A>
367
367
-
)}
368
368
-
</For>
369
369
-
</div>
370
370
-
</Show>
371
371
-
</>
372
372
-
)}
373
373
-
</For>
374
374
-
</div>
375
375
-
</div>
376
376
-
</Show>
377
377
-
<Show when={location.hash === "#identity"}>
378
378
-
<Show when={didDoc()}>
379
379
-
{(didDocument) => (
380
380
-
<div class="flex flex-col gap-y-2 wrap-anywhere">
381
381
-
<div class="flex flex-col gap-y-1">
382
382
-
<div class="flex items-baseline justify-between gap-2">
383
383
-
<div>
384
384
-
<div class="flex items-center gap-1">
385
385
-
<div class="iconify lucide--id-card" />
386
386
-
<p class="font-semibold">ID</p>
387
387
-
</div>
388
388
-
<div class="text-sm">{didDocument().id}</div>
389
389
-
</div>
390
390
-
<Tooltip text="DID document">
391
391
-
<a
392
392
-
href={
393
393
-
did.startsWith("did:plc") ?
394
394
-
`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}`
395
395
-
: `https://${did.split("did:web:")[1]}/.well-known/did.json`
396
396
-
}
397
397
-
target="_blank"
398
398
-
class="-mr-1 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"
399
399
-
>
400
400
-
<span class="iconify lucide--external-link"></span>
401
401
-
</a>
402
402
-
</Tooltip>
403
403
-
</div>
404
404
-
<div>
405
405
-
<div class="flex items-center gap-1">
406
406
-
<div class="iconify lucide--at-sign" />
407
407
-
<p class="font-semibold">Aliases</p>
408
408
-
</div>
409
409
-
<ul>
410
410
-
<For each={didDocument().alsoKnownAs}>
411
411
-
{(alias) => <li class="text-sm">{alias}</li>}
412
412
-
</For>
413
413
-
</ul>
414
414
-
</div>
415
415
-
<div>
416
416
-
<div class="flex items-center gap-1">
417
417
-
<div class="iconify lucide--hard-drive" />
418
418
-
<p class="font-semibold">Services</p>
419
419
-
</div>
420
420
-
<ul>
421
421
-
<For each={didDocument().service}>
422
422
-
{(service) => (
423
423
-
<li class="flex flex-col text-sm">
424
424
-
<span>#{service.id.split("#")[1]}</span>
425
425
-
<a
426
426
-
class="w-fit text-blue-400 hover:underline active:underline"
427
427
-
href={service.serviceEndpoint.toString()}
428
428
-
target="_blank"
429
429
-
>
430
430
-
{service.serviceEndpoint.toString()}
431
431
-
</a>
432
432
-
</li>
433
433
-
)}
434
434
-
</For>
435
435
-
</ul>
436
436
-
</div>
437
437
-
<div>
438
438
-
<div class="flex items-center gap-1">
439
439
-
<div class="iconify lucide--shield-check" />
440
440
-
<p class="font-semibold">Verification methods</p>
441
441
-
</div>
442
442
-
<ul>
443
443
-
<For each={didDocument().verificationMethod}>
444
444
-
{(verif) => (
445
445
-
<Show when={verif.publicKeyMultibase}>
446
446
-
{(key) => (
447
447
-
<li class="flex flex-col text-sm">
448
448
-
<span class="flex justify-between gap-1">
449
449
-
<span>#{verif.id.split("#")[1]}</span>
450
450
-
<span class="flex items-center gap-0.5">
451
451
-
<div class="iconify lucide--key-round" />
452
452
-
<ErrorBoundary fallback={<>unknown</>}>
453
453
-
{parsePublicMultikey(key()).type}
454
454
-
</ErrorBoundary>
455
455
-
</span>
456
456
-
</span>
457
457
-
<span class="truncate text-xs">{key()}</span>
458
458
-
</li>
459
459
-
)}
460
460
-
</Show>
461
461
-
)}
462
462
-
</For>
463
463
-
</ul>
464
464
-
</div>
465
465
-
</div>
466
466
-
<div class="flex justify-between">
467
467
-
<Show when={did.startsWith("did:plc")}>
468
468
-
<div class="flex items-center gap-1">
469
469
-
<Button
470
470
-
onClick={async () => {
471
471
-
if (!plcOps()) {
472
472
-
setLoading(true);
473
473
-
const response = await fetch(
474
474
-
`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}/log/audit`,
475
475
-
);
476
476
-
const json = await response.json();
477
477
-
try {
478
478
-
const logs = defs.indexedEntryLog.parse(json);
479
479
-
try {
480
480
-
await processIndexedEntryLog(did as any, logs);
481
481
-
} catch (e) {
482
482
-
console.error(e);
483
483
-
}
484
484
-
const opHistory = createOperationHistory(logs).reverse();
485
485
-
setPlcOps(Array.from(groupBy(opHistory, (item) => item.orig)));
486
486
-
setLoading(false);
487
487
-
} catch (e: any) {
488
488
-
setNotice(e);
489
489
-
console.error(e);
490
490
-
setLoading(false);
491
491
-
}
492
492
-
}
493
493
-
494
494
-
setShowPlcLogs(!showPlcLogs());
495
495
-
}}
496
496
-
>
497
497
-
<span class="iconify lucide--logs text-sm"></span>
498
498
-
{showPlcLogs() ? "Hide" : "Show"} PLC Logs
499
499
-
</Button>
500
500
-
<Show when={loading()}>
501
501
-
<div class="iconify lucide--loader-circle animate-spin text-xl" />
502
502
-
</Show>
503
503
-
</div>
504
504
-
</Show>
505
505
-
<Show when={error()?.length === 0 || error() === undefined}>
506
506
-
<div
507
507
-
classList={{
508
508
-
"flex items-center gap-1": true,
509
509
-
"flex-row-reverse": did.startsWith("did:web"),
510
510
-
}}
511
511
-
>
512
512
-
<Show when={downloading()}>
513
513
-
<div class="iconify lucide--loader-circle animate-spin text-xl" />
514
514
-
</Show>
515
515
-
<Button onClick={() => downloadRepo()}>
516
516
-
<span class="iconify lucide--download text-sm"></span>
517
517
-
Export Repo
518
518
-
</Button>
519
519
-
</div>
520
520
-
</Show>
521
521
-
</div>
522
522
-
<Show when={showPlcLogs()}>
523
523
-
<Show when={notice()}>
524
524
-
<div>{notice()}</div>
525
525
-
</Show>
526
526
-
<PlcLogView plcOps={plcOps() ?? []} did={did} />
527
527
-
</Show>
528
528
-
</div>
529
529
-
)}
530
530
-
</Show>
531
531
-
</Show>
532
532
-
</div>
533
533
-
</Show>
534
534
-
);
535
535
-
};
536
536
-
537
537
-
export { RepoView };
-48
src/views/settings.tsx
···
1
1
-
import { createSignal } from "solid-js";
2
2
-
import { TextInput } from "../components/text-input.jsx";
3
3
-
4
4
-
export const [hideMedia, setHideMedia] = createSignal(localStorage.hideMedia === "true");
5
5
-
6
6
-
const Settings = () => {
7
7
-
return (
8
8
-
<div class="flex w-[22rem] flex-col gap-3 sm:w-[24rem]">
9
9
-
<div class="flex items-center gap-1 font-semibold">
10
10
-
<span>Settings</span>
11
11
-
</div>
12
12
-
<div class="flex flex-col gap-2">
13
13
-
<div class="flex flex-col gap-0.5">
14
14
-
<label for="plcDirectory" class="select-none">
15
15
-
PLC Directory
16
16
-
</label>
17
17
-
<TextInput
18
18
-
id="plcDirectory"
19
19
-
value={localStorage.plcDirectory || "https://plc.directory"}
20
20
-
onInput={(e) => {
21
21
-
e.currentTarget.value.length ?
22
22
-
(localStorage.plcDirectory = e.currentTarget.value)
23
23
-
: localStorage.removeItem("plcDirectory");
24
24
-
}}
25
25
-
/>
26
26
-
</div>
27
27
-
<div class="flex justify-between">
28
28
-
<div class="flex items-center gap-1">
29
29
-
<input
30
30
-
id="disableMedia"
31
31
-
type="checkbox"
32
32
-
checked={localStorage.hideMedia === "true"}
33
33
-
onChange={(e) => {
34
34
-
localStorage.hideMedia = e.currentTarget.checked;
35
35
-
setHideMedia(e.currentTarget.checked);
36
36
-
}}
37
37
-
/>
38
38
-
<label for="disableMedia" class="select-none">
39
39
-
Hide media embeds
40
40
-
</label>
41
41
-
</div>
42
42
-
</div>
43
43
-
</div>
44
44
-
</div>
45
45
-
);
46
46
-
};
47
47
-
48
48
-
export { Settings };
-262
src/views/stream.tsx
···
1
1
-
import { Firehose } from "@skyware/firehose";
2
2
-
import { A, useLocation, useSearchParams } from "@solidjs/router";
3
3
-
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
4
4
-
import { Button } from "../components/button";
5
5
-
import { JSONValue } from "../components/json";
6
6
-
import { StickyOverlay } from "../components/sticky";
7
7
-
import { TextInput } from "../components/text-input";
8
8
-
9
9
-
const LIMIT = 25;
10
10
-
type Parameter = { name: string; param: string | string[] | undefined };
11
11
-
12
12
-
const StreamView = () => {
13
13
-
const [searchParams, setSearchParams] = useSearchParams();
14
14
-
const [parameters, setParameters] = createSignal<Parameter[]>([]);
15
15
-
const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream";
16
16
-
const [records, setRecords] = createSignal<Array<any>>([]);
17
17
-
const [connected, setConnected] = createSignal(false);
18
18
-
const [notice, setNotice] = createSignal("");
19
19
-
let socket: WebSocket;
20
20
-
let firehose: Firehose;
21
21
-
let formRef!: HTMLFormElement;
22
22
-
23
23
-
const connectSocket = async (formData: FormData) => {
24
24
-
setNotice("");
25
25
-
if (connected()) {
26
26
-
if (streamType === "jetstream") socket?.close();
27
27
-
else firehose?.close();
28
28
-
setConnected(false);
29
29
-
return;
30
30
-
}
31
31
-
setRecords([]);
32
32
-
33
33
-
let url = "";
34
34
-
if (streamType === "jetstream") {
35
35
-
url =
36
36
-
formData.get("instance")?.toString() ?? "wss://jetstream1.us-east.bsky.network/subscribe";
37
37
-
url = url.concat("?");
38
38
-
} else {
39
39
-
url = formData.get("instance")?.toString() ?? "wss://bsky.network";
40
40
-
}
41
41
-
42
42
-
const collections = formData.get("collections")?.toString().split(",");
43
43
-
collections?.forEach((collection) => {
44
44
-
if (collection.length) url = url.concat(`wantedCollections=${collection}&`);
45
45
-
});
46
46
-
47
47
-
const dids = formData.get("dids")?.toString().split(",");
48
48
-
dids?.forEach((did) => {
49
49
-
if (did.length) url = url.concat(`wantedDids=${did}&`);
50
50
-
});
51
51
-
52
52
-
const cursor = formData.get("cursor")?.toString();
53
53
-
if (streamType === "jetstream") {
54
54
-
if (cursor?.length) url = url.concat(`cursor=${cursor}`);
55
55
-
if (url.endsWith("&")) url = url.slice(0, -1);
56
56
-
}
57
57
-
58
58
-
setSearchParams({
59
59
-
instance: formData.get("instance")?.toString(),
60
60
-
collections: formData.get("collections")?.toString(),
61
61
-
dids: formData.get("dids")?.toString(),
62
62
-
cursor: formData.get("cursor")?.toString(),
63
63
-
allEvents: formData.get("allEvents")?.toString(),
64
64
-
});
65
65
-
66
66
-
setParameters([
67
67
-
{ name: "Instance", param: formData.get("instance")?.toString() },
68
68
-
{ name: "Collections", param: formData.get("collections")?.toString() },
69
69
-
{ name: "DIDs", param: formData.get("dids")?.toString() },
70
70
-
{ name: "Cursor", param: formData.get("cursor")?.toString() },
71
71
-
{ name: "All Events", param: formData.get("allEvents")?.toString() },
72
72
-
]);
73
73
-
74
74
-
setConnected(true);
75
75
-
if (streamType === "jetstream") {
76
76
-
socket = new WebSocket(url);
77
77
-
socket.addEventListener("message", (event) => {
78
78
-
const rec = JSON.parse(event.data);
79
79
-
if (searchParams.allEvents === "on" || (rec.kind !== "account" && rec.kind !== "identity"))
80
80
-
setRecords(records().concat(rec).slice(-LIMIT));
81
81
-
});
82
82
-
socket.addEventListener("error", () => {
83
83
-
setNotice("Connection error");
84
84
-
setConnected(false);
85
85
-
});
86
86
-
} else {
87
87
-
firehose = new Firehose({
88
88
-
relay: url,
89
89
-
cursor: cursor,
90
90
-
autoReconnect: false,
91
91
-
});
92
92
-
firehose.on("error", (err) => {
93
93
-
console.error(err);
94
94
-
});
95
95
-
firehose.on("commit", (commit) => {
96
96
-
for (const op of commit.ops) {
97
97
-
const record = {
98
98
-
$type: commit.$type,
99
99
-
repo: commit.repo,
100
100
-
seq: commit.seq,
101
101
-
time: commit.time,
102
102
-
rev: commit.rev,
103
103
-
since: commit.since,
104
104
-
op: op,
105
105
-
};
106
106
-
setRecords(records().concat(record).slice(-LIMIT));
107
107
-
}
108
108
-
});
109
109
-
firehose.on("identity", (identity) => {
110
110
-
setRecords(records().concat(identity).slice(-LIMIT));
111
111
-
});
112
112
-
firehose.on("account", (account) => {
113
113
-
setRecords(records().concat(account).slice(-LIMIT));
114
114
-
});
115
115
-
firehose.on("sync", (sync) => {
116
116
-
const event = {
117
117
-
$type: sync.$type,
118
118
-
did: sync.did,
119
119
-
rev: sync.rev,
120
120
-
seq: sync.seq,
121
121
-
time: sync.time,
122
122
-
};
123
123
-
setRecords(records().concat(event).slice(-LIMIT));
124
124
-
});
125
125
-
firehose.start();
126
126
-
}
127
127
-
};
128
128
-
129
129
-
onMount(async () => {
130
130
-
const formData = new FormData();
131
131
-
if (searchParams.instance) formData.append("instance", searchParams.instance.toString());
132
132
-
if (searchParams.collections)
133
133
-
formData.append("collections", searchParams.collections.toString());
134
134
-
if (searchParams.dids) formData.append("dids", searchParams.dids.toString());
135
135
-
if (searchParams.cursor) formData.append("cursor", searchParams.cursor.toString());
136
136
-
if (searchParams.allEvents) formData.append("allEvents", searchParams.allEvents.toString());
137
137
-
if (searchParams.instance) connectSocket(formData);
138
138
-
});
139
139
-
140
140
-
onCleanup(() => socket?.close());
141
141
-
142
142
-
return (
143
143
-
<div class="flex flex-col items-center">
144
144
-
<div class="flex gap-2 text-sm">
145
145
-
<A
146
146
-
class="flex items-center gap-1 border-b-2 p-1"
147
147
-
inactiveClass="border-transparent hover:border-neutral-400 dark:hover:border-neutral-600"
148
148
-
href="/jetstream"
149
149
-
>
150
150
-
<span class="iconify lucide--radio-tower"></span>
151
151
-
Jetstream
152
152
-
</A>
153
153
-
<A
154
154
-
class="flex items-center gap-1 border-b-2 p-1"
155
155
-
inactiveClass="border-transparent hover:border-neutral-400 dark:hover:border-neutral-600"
156
156
-
href="/firehose"
157
157
-
>
158
158
-
<span class="iconify lucide--waves"></span>
159
159
-
Firehose
160
160
-
</A>
161
161
-
</div>
162
162
-
<StickyOverlay>
163
163
-
<form ref={formRef} class="flex w-[22rem] flex-col gap-1 text-sm sm:w-[24rem]">
164
164
-
<Show when={!connected()}>
165
165
-
<label class="flex items-center justify-end gap-x-1">
166
166
-
<span class="min-w-[5rem]">Instance</span>
167
167
-
<TextInput
168
168
-
name="instance"
169
169
-
value={
170
170
-
searchParams.instance ??
171
171
-
(streamType === "jetstream" ?
172
172
-
"wss://jetstream1.us-east.bsky.network/subscribe"
173
173
-
: "wss://bsky.network")
174
174
-
}
175
175
-
class="grow"
176
176
-
/>
177
177
-
</label>
178
178
-
<Show when={streamType === "jetstream"}>
179
179
-
<label class="flex items-center justify-end gap-x-1">
180
180
-
<span class="min-w-[5rem]">Collections</span>
181
181
-
<textarea
182
182
-
name="collections"
183
183
-
spellcheck={false}
184
184
-
placeholder="Comma-separated list of collections"
185
185
-
value={searchParams.collections ?? ""}
186
186
-
class="dark:bg-dark-100 dark:shadow-dark-800 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 shadow-xs focus:outline-[1.5px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200"
187
187
-
/>
188
188
-
</label>
189
189
-
</Show>
190
190
-
<Show when={streamType === "jetstream"}>
191
191
-
<label class="flex items-center justify-end gap-x-1">
192
192
-
<span class="min-w-[5rem]">DIDs</span>
193
193
-
<textarea
194
194
-
name="dids"
195
195
-
spellcheck={false}
196
196
-
placeholder="Comma-separated list of DIDs"
197
197
-
value={searchParams.dids ?? ""}
198
198
-
class="dark:bg-dark-100 dark:shadow-dark-800 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 shadow-xs focus:outline-[1.5px] focus:outline-neutral-900 dark:border-neutral-700 dark:focus:outline-neutral-200"
199
199
-
/>
200
200
-
</label>
201
201
-
</Show>
202
202
-
<label class="flex items-center justify-end gap-x-1">
203
203
-
<span class="min-w-[5rem]">Cursor</span>
204
204
-
<TextInput
205
205
-
name="cursor"
206
206
-
placeholder="Leave empty for live-tail"
207
207
-
value={searchParams.cursor ?? ""}
208
208
-
class="grow"
209
209
-
/>
210
210
-
</label>
211
211
-
<Show when={streamType === "jetstream"}>
212
212
-
<div class="flex items-center justify-end gap-x-1">
213
213
-
<input
214
214
-
type="checkbox"
215
215
-
name="allEvents"
216
216
-
id="allEvents"
217
217
-
checked={searchParams.allEvents === "on" ? true : false}
218
218
-
/>
219
219
-
<label for="allEvents" class="select-none">
220
220
-
Show account and identity events
221
221
-
</label>
222
222
-
</div>
223
223
-
</Show>
224
224
-
</Show>
225
225
-
<Show when={connected()}>
226
226
-
<div class="flex flex-col gap-1 wrap-anywhere">
227
227
-
<For each={parameters()}>
228
228
-
{(param) => (
229
229
-
<Show when={param.param}>
230
230
-
<div class="flex">
231
231
-
<div class="min-w-[6rem] font-semibold">{param.name}</div>
232
232
-
{param.param}
233
233
-
</div>
234
234
-
</Show>
235
235
-
)}
236
236
-
</For>
237
237
-
</div>
238
238
-
</Show>
239
239
-
<div class="flex justify-end">
240
240
-
<Button onClick={() => connectSocket(new FormData(formRef))}>
241
241
-
{connected() ? "Disconnect" : "Connect"}
242
242
-
</Button>
243
243
-
</div>
244
244
-
</form>
245
245
-
</StickyOverlay>
246
246
-
<Show when={notice().length}>
247
247
-
<div class="text-red-500 dark:text-red-400">{notice()}</div>
248
248
-
</Show>
249
249
-
<div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 px-4 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-[48rem]">
250
250
-
<For each={records().toReversed()}>
251
251
-
{(rec) => (
252
252
-
<div class="pb-2">
253
253
-
<JSONValue data={rec} repo={rec.did ?? rec.repo} />
254
254
-
</div>
255
255
-
)}
256
256
-
</For>
257
257
-
</div>
258
258
-
</div>
259
259
-
);
260
260
-
};
261
261
-
262
262
-
export { StreamView };