forked from
pds.ls/pdsls
atmosphere explorer
1import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto";
2import { Client, simpleFetchHandler } from "@atcute/client";
3import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons";
4import * as TID from "@atcute/tid";
5import { Title } from "@solidjs/meta";
6import { A, useBeforeLeave, useParams, useSearchParams } from "@solidjs/router";
7import { createMemo, createResource, createSignal, For, onMount, Show } from "solid-js";
8import { createStore } from "solid-js/store";
9import { agent } from "../auth/state";
10import { Button } from "../components/button.jsx";
11import HoverCard from "../components/hover-card/base";
12import { JSONType, JSONValue } from "../components/json.jsx";
13import { Modal } from "../components/modal.jsx";
14import { addNotification, removeNotification } from "../components/notification.jsx";
15import { PermissionButton } from "../components/permission-button.jsx";
16import { StickyOverlay } from "../components/sticky.jsx";
17import { TextInput } from "../components/text-input.jsx";
18import Tooltip from "../components/tooltip.jsx";
19import { resolvePDS } from "../utils/api.js";
20import { localDateFromTimestamp } from "../utils/date.js";
21import {
22 clearCollectionCache,
23 getCollectionCache,
24 setCollectionCache,
25} from "../utils/route-cache.js";
26
27interface AtprotoRecord {
28 rkey: string;
29 cid: string;
30 record: InferXRPCBodyOutput<ComAtprotoRepoGetRecord.mainSchema["output"]>;
31 timestamp: number | undefined;
32 toDelete: boolean;
33}
34
35const DEFAULT_LIMIT = 100;
36
37const RecordLink = (props: { record: AtprotoRecord }) => {
38 return (
39 <HoverCard
40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5"
41 trigger={
42 <>
43 <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400">
44 {props.record.rkey}
45 </span>
46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl">
47 {props.record.cid}
48 </span>
49 <Show when={props.record.timestamp && props.record.timestamp <= Date.now()}>
50 <span class="ml-1 shrink-0 text-xs">
51 {localDateFromTimestamp(props.record.timestamp!)}
52 </span>
53 </Show>
54 </>
55 }
56 >
57 <JSONValue
58 data={props.record.record.value as JSONType}
59 repo={props.record.record.uri.split("/")[2]}
60 truncate
61 hideBlobs
62 />
63 </HoverCard>
64 );
65};
66
67const CollectionView = () => {
68 const params = useParams();
69 const [searchParams, setSearchParams] = useSearchParams();
70 const [cursor, setCursor] = createSignal<string>();
71 const [records, setRecords] = createStore<AtprotoRecord[]>([]);
72 const [filter, setFilter] = createSignal<string>();
73 const [batchDelete, setBatchDelete] = createSignal(false);
74 const [lastSelected, setLastSelected] = createSignal<number>();
75 const [reverse, setReverse] = createSignal(searchParams.reverse === "true");
76 const limit = () => {
77 const limitParam =
78 Array.isArray(searchParams.limit) ? searchParams.limit[0] : searchParams.limit;
79 const paramLimit = parseInt(limitParam || "");
80 return !isNaN(paramLimit) && paramLimit > 0 && paramLimit <= 100 ? paramLimit : DEFAULT_LIMIT;
81 };
82 const [recreate, setRecreate] = createSignal(false);
83 const [openDelete, setOpenDelete] = createSignal(false);
84 const [restoredFromCache, setRestoredFromCache] = createSignal(false);
85 const did = params.repo;
86 let pds: string;
87 let rpc: Client;
88
89 const cacheKey = () => `${params.pds}/${params.repo}/${params.collection}`;
90
91 onMount(() => {
92 const cached = getCollectionCache(cacheKey());
93 if (cached) {
94 setRecords(cached.records as AtprotoRecord[]);
95 setCursor(cached.cursor);
96 setReverse(cached.reverse);
97 setSearchParams({
98 reverse: cached.reverse ? "true" : undefined,
99 limit: cached.limit !== DEFAULT_LIMIT ? cached.limit.toString() : undefined,
100 });
101 setRestoredFromCache(true);
102 requestAnimationFrame(() => {
103 window.scrollTo(0, cached.scrollY);
104 });
105 }
106 });
107
108 useBeforeLeave((e) => {
109 const recordPathPrefix = `/at://${did}/${params.collection}/`;
110 const isNavigatingToRecord = typeof e.to === "string" && e.to.startsWith(recordPathPrefix);
111
112 if (isNavigatingToRecord && records.length > 0) {
113 setCollectionCache(cacheKey(), {
114 records: [...records],
115 cursor: cursor(),
116 scrollY: window.scrollY,
117 reverse: reverse(),
118 limit: limit(),
119 });
120 } else {
121 clearCollectionCache(cacheKey());
122 }
123 });
124
125 const fetchRecords = async () => {
126 if (restoredFromCache() && records.length > 0 && !cursor()) {
127 setRestoredFromCache(false);
128 return records;
129 }
130 if (restoredFromCache()) setRestoredFromCache(false);
131
132 const isLoadMore = cursor() !== undefined;
133
134 if (!pds) pds = await resolvePDS(did!);
135 if (!rpc) rpc = new Client({ handler: simpleFetchHandler({ service: pds }) });
136 const res = await rpc.get("com.atproto.repo.listRecords", {
137 params: {
138 repo: did as ActorIdentifier,
139 collection: params.collection as `${string}.${string}.${string}`,
140 limit: limit(),
141 cursor: cursor(),
142 reverse: reverse(),
143 },
144 });
145 if (!res.ok) throw new Error(res.data.error);
146 setCursor(res.data.records.length < limit() ? undefined : res.data.cursor);
147 const tmpRecords: AtprotoRecord[] = [];
148 res.data.records.forEach((record) => {
149 const rkey = record.uri.split("/").pop()!;
150 tmpRecords.push({
151 rkey: rkey,
152 cid: record.cid,
153 record: record,
154 timestamp: TID.validate(rkey) ? TID.parse(rkey).timestamp / 1000 : undefined,
155 toDelete: false,
156 });
157 });
158 setRecords(isLoadMore ? records.concat(tmpRecords) : tmpRecords);
159 return res.data.records;
160 };
161
162 const [response, { refetch }] = createResource(fetchRecords);
163
164 const filteredRecords = createMemo(() =>
165 records.filter((rec) =>
166 filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true,
167 ),
168 );
169
170 const deleteRecords = async () => {
171 const recsToDel = records.filter((record) => record.toDelete);
172 let writes: Array<
173 | $type.enforce<ComAtprotoRepoApplyWrites.Delete>
174 | $type.enforce<ComAtprotoRepoApplyWrites.Create>
175 > = [];
176 recsToDel.forEach((record) => {
177 writes.push({
178 $type: "com.atproto.repo.applyWrites#delete",
179 collection: params.collection as `${string}.${string}.${string}`,
180 rkey: record.rkey,
181 });
182 if (recreate()) {
183 writes.push({
184 $type: "com.atproto.repo.applyWrites#create",
185 collection: params.collection as `${string}.${string}.${string}`,
186 rkey: record.rkey,
187 value: record.record.value,
188 });
189 }
190 });
191
192 const BATCHSIZE = 200;
193 rpc = new Client({ handler: agent()! });
194 for (let i = 0; i < writes.length; i += BATCHSIZE) {
195 await rpc.post("com.atproto.repo.applyWrites", {
196 input: {
197 repo: agent()!.sub,
198 writes: writes.slice(i, i + BATCHSIZE),
199 },
200 });
201 }
202 const id = addNotification({
203 message: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`,
204 type: "success",
205 });
206 setTimeout(() => removeNotification(id), 3000);
207 setBatchDelete(false);
208 setRecords([]);
209 setCursor(undefined);
210 setOpenDelete(false);
211 setRecreate(false);
212 clearCollectionCache(cacheKey());
213 refetch();
214 };
215
216 const handleSelectionClick = (e: MouseEvent, index: number) => {
217 if (e.shiftKey && lastSelected() !== undefined)
218 setRecords(
219 {
220 from: lastSelected()! < index ? lastSelected() : index + 1,
221 to: index > lastSelected()! ? index - 1 : lastSelected(),
222 },
223 "toDelete",
224 true,
225 );
226 else setLastSelected(index);
227 };
228
229 const selectAll = () =>
230 setRecords(
231 records
232 .map((record, index) =>
233 JSON.stringify(record.record.value).includes(filter() ?? "") ? index : undefined,
234 )
235 .filter((i) => i !== undefined),
236 "toDelete",
237 true,
238 );
239
240 return (
241 <>
242 <Title>{params.collection} - PDSls</Title>
243 <Show when={records.length || response()}>
244 <div class="-mt-2 flex w-full flex-col items-center">
245 <StickyOverlay>
246 <div class="flex w-full flex-col gap-2">
247 <div class="flex items-center gap-1.5">
248 <Show when={agent() && agent()?.sub === did}>
249 <div class="flex items-center">
250 <PermissionButton
251 scope="delete"
252 tooltip={batchDelete() ? "Cancel" : "Manage"}
253 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
254 disabledClass="flex items-center rounded-md p-1.5 opacity-40"
255 onClick={() => {
256 setRecords({ from: 0, to: records.length - 1 }, "toDelete", false);
257 setLastSelected(undefined);
258 setBatchDelete(!batchDelete());
259 }}
260 >
261 <span
262 class={`iconify ${batchDelete() ? "lucide--x" : "lucide--trash-2"} `}
263 ></span>
264 </PermissionButton>
265 <Show when={batchDelete()}>
266 <Tooltip
267 text="Select all"
268 children={
269 <button
270 onclick={() => selectAll()}
271 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
272 >
273 <span class="iconify lucide--list-checks"></span>
274 </button>
275 }
276 />
277 <PermissionButton
278 scope="create"
279 tooltip="Recreate"
280 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
281 disabledClass="flex items-center rounded-md p-1.5 opacity-40"
282 onClick={() => {
283 setRecreate(true);
284 setOpenDelete(true);
285 }}
286 >
287 <span class="iconify lucide--recycle text-green-500 dark:text-green-400"></span>
288 </PermissionButton>
289 <Tooltip
290 text="Delete"
291 children={
292 <button
293 onclick={() => {
294 setRecreate(false);
295 setOpenDelete(true);
296 }}
297 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
298 >
299 <span class="iconify lucide--trash-2 text-red-500 dark:text-red-400"></span>
300 </button>
301 }
302 />
303 </Show>
304 </div>
305 <Modal
306 open={openDelete()}
307 onClose={() => setOpenDelete(false)}
308 contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700"
309 >
310 <h2 class="mb-2 font-semibold">
311 {recreate() ? "Recreate" : "Delete"}{" "}
312 {records.filter((r) => r.toDelete).length} records?
313 </h2>
314 <div class="flex justify-end gap-2">
315 <Button onClick={() => setOpenDelete(false)}>Cancel</Button>
316 <Button
317 onClick={deleteRecords}
318 classList={{
319 "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!":
320 recreate(),
321 "text-white! border-none! bg-red-500! hover:bg-red-600! active:bg-red-700!":
322 !recreate(),
323 }}
324 >
325 {recreate() ? "Recreate" : "Delete"}
326 </Button>
327 </div>
328 </Modal>
329 </Show>
330 <TextInput
331 name="Filter"
332 placeholder="Filter records"
333 onInput={(e) => setFilter(e.currentTarget.value)}
334 class="grow text-sm"
335 />
336 <Tooltip text="Jetstream">
337 <A
338 href={`/jetstream?collections=${params.collection}&dids=${params.repo}`}
339 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
340 >
341 <span class="iconify lucide--radio-tower"></span>
342 </A>
343 </Tooltip>
344 </div>
345 <Show when={records.length > 1}>
346 <div class="flex items-center justify-between gap-x-2">
347 <Button
348 onClick={() => {
349 const newReverse = !reverse();
350 setReverse(newReverse);
351 setSearchParams({ reverse: newReverse ? "true" : undefined });
352 setCursor(undefined);
353 setRestoredFromCache(false);
354 clearCollectionCache(cacheKey());
355 refetch();
356 }}
357 classList={{
358 "text-blue-500! dark:text-blue-400! border-blue-500! dark:border-blue-400!":
359 reverse(),
360 }}
361 >
362 <span
363 class={`iconify ${reverse() ? "lucide--arrow-down-wide-narrow" : "lucide--arrow-up-narrow-wide"}`}
364 ></span>
365 Reverse
366 </Button>
367 <div>
368 <Show when={batchDelete()}>
369 <span>{records.filter((rec) => rec.toDelete).length}</span>
370 <span>/</span>
371 </Show>
372 <span>{filter() ? filteredRecords().length : records.length} records</span>
373 </div>
374 <div class="flex w-20 items-center justify-end">
375 <Show when={cursor()}>
376 <Show when={!response.loading}>
377 <Button onClick={() => refetch()}>Load more</Button>
378 </Show>
379 <Show when={response.loading}>
380 <div class="iconify lucide--loader-circle w-20 animate-spin text-lg" />
381 </Show>
382 </Show>
383 </div>
384 </div>
385 </Show>
386 </div>
387 </StickyOverlay>
388 <div class="flex max-w-full flex-col px-2 font-mono">
389 <For each={filteredRecords()}>
390 {(record, index) => {
391 const rounding = () => {
392 const recs = filteredRecords();
393 const prevSelected = recs[index() - 1]?.toDelete;
394 const nextSelected = recs[index() + 1]?.toDelete;
395 return `${!prevSelected ? "rounded-t" : ""} ${!nextSelected ? "rounded-b" : ""}`;
396 };
397 return (
398 <>
399 <Show when={batchDelete()}>
400 <div
401 class={`select-none ${
402 record.toDelete ?
403 `bg-blue-200 hover:bg-blue-300/80 active:bg-blue-300 dark:bg-blue-700/30 dark:hover:bg-blue-700/50 dark:active:bg-blue-700/70 ${rounding()}`
404 : "rounded hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
405 }`}
406 onclick={(e) => {
407 handleSelectionClick(e, index());
408 setRecords(index(), "toDelete", !record.toDelete);
409 }}
410 >
411 <RecordLink record={record} />
412 </div>
413 </Show>
414 <Show when={!batchDelete()}>
415 <A
416 href={`/at://${did}/${params.collection}/${record.rkey}`}
417 class="rounded select-none hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
418 >
419 <RecordLink record={record} />
420 </A>
421 </Show>
422 </>
423 );
424 }}
425 </For>
426 </div>
427 </div>
428 </Show>
429 </>
430 );
431};
432
433export { CollectionView };