1import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto";
2import { Client, CredentialManager } from "@atcute/client";
3import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons";
4import * as TID from "@atcute/tid";
5import { A, useParams } from "@solidjs/router";
6import {
7 createEffect,
8 createMemo,
9 createResource,
10 createSignal,
11 For,
12 Show,
13 untrack,
14} from "solid-js";
15import { createStore } from "solid-js/store";
16import { Button } from "../components/button.jsx";
17import { JSONType, JSONValue } from "../components/json.jsx";
18import { agent } from "../components/login.jsx";
19import { Modal } from "../components/modal.jsx";
20import { addNotification, removeNotification } from "../components/notification.jsx";
21import { StickyOverlay } from "../components/sticky.jsx";
22import { TextInput } from "../components/text-input.jsx";
23import Tooltip from "../components/tooltip.jsx";
24import { resolvePDS } from "../utils/api.js";
25import { localDateFromTimestamp } from "../utils/date.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 LIMIT = 100;
36
37const RecordLink = (props: { record: AtprotoRecord }) => {
38 const [hover, setHover] = createSignal(false);
39 const [previewHeight, setPreviewHeight] = createSignal(0);
40 let rkeyRef!: HTMLSpanElement;
41 let previewRef!: HTMLSpanElement;
42
43 createEffect(() => {
44 if (hover()) setPreviewHeight(previewRef.offsetHeight);
45 });
46
47 const isOverflowing = (previewHeight: number) =>
48 rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight;
49
50 return (
51 <span
52 class="relative flex w-full min-w-0 items-baseline rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
53 ref={rkeyRef}
54 onmouseover={() => setHover(true)}
55 onmouseleave={() => setHover(false)}
56 >
57 <span class="flex items-baseline truncate">
58 <span class="shrink-0 text-sm text-blue-400 sm:text-base">{props.record.rkey}</span>
59 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl">
60 {props.record.cid}
61 </span>
62 <Show when={props.record.timestamp && props.record.timestamp <= Date.now()}>
63 <span class="ml-1 shrink-0 text-xs">
64 {localDateFromTimestamp(props.record.timestamp!)}
65 </span>
66 </Show>
67 </span>
68 <Show when={hover()}>
69 <span
70 ref={previewRef}
71 class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none absolute left-[50%] z-25 block max-h-80 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-112 lg:max-w-lg dark:border-neutral-700 ${isOverflowing(previewHeight()) ? "bottom-7" : "top-7"}`}
72 >
73 <JSONValue
74 data={props.record.record.value as JSONType}
75 repo={props.record.record.uri.split("/")[2]}
76 />
77 </span>
78 </Show>
79 </span>
80 );
81};
82
83const CollectionView = () => {
84 const params = useParams();
85 const [cursor, setCursor] = createSignal<string>();
86 const [records, setRecords] = createStore<AtprotoRecord[]>([]);
87 const [filter, setFilter] = createSignal<string>();
88 const [batchDelete, setBatchDelete] = createSignal(false);
89 const [lastSelected, setLastSelected] = createSignal<number>();
90 const [reverse, setReverse] = createSignal(false);
91 const [recreate, setRecreate] = createSignal(false);
92 const [openDelete, setOpenDelete] = createSignal(false);
93 const did = params.repo;
94 let pds: string;
95 let rpc: Client;
96
97 const fetchRecords = async () => {
98 if (!pds) pds = await resolvePDS(did!);
99 if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: pds }) });
100 const res = await rpc.get("com.atproto.repo.listRecords", {
101 params: {
102 repo: did as ActorIdentifier,
103 collection: params.collection as `${string}.${string}.${string}`,
104 limit: LIMIT,
105 cursor: cursor(),
106 reverse: reverse(),
107 },
108 });
109 if (!res.ok) throw new Error(res.data.error);
110 setCursor(res.data.records.length < LIMIT ? undefined : res.data.cursor);
111 const tmpRecords: AtprotoRecord[] = [];
112 res.data.records.forEach((record) => {
113 const rkey = record.uri.split("/").pop()!;
114 tmpRecords.push({
115 rkey: rkey,
116 cid: record.cid,
117 record: record,
118 timestamp: TID.validate(rkey) ? TID.parse(rkey).timestamp / 1000 : undefined,
119 toDelete: false,
120 });
121 });
122 setRecords(records.concat(tmpRecords) ?? tmpRecords);
123 return res.data.records;
124 };
125
126 const [response, { refetch }] = createResource(fetchRecords);
127
128 const filteredRecords = createMemo(() =>
129 records.filter((rec) =>
130 filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true,
131 ),
132 );
133
134 const deleteRecords = async () => {
135 const recsToDel = records.filter((record) => record.toDelete);
136 let writes: Array<
137 | $type.enforce<ComAtprotoRepoApplyWrites.Delete>
138 | $type.enforce<ComAtprotoRepoApplyWrites.Create>
139 > = [];
140 recsToDel.forEach((record) => {
141 writes.push({
142 $type: "com.atproto.repo.applyWrites#delete",
143 collection: params.collection as `${string}.${string}.${string}`,
144 rkey: record.rkey,
145 });
146 if (recreate()) {
147 writes.push({
148 $type: "com.atproto.repo.applyWrites#create",
149 collection: params.collection as `${string}.${string}.${string}`,
150 rkey: record.rkey,
151 value: record.record.value,
152 });
153 }
154 });
155
156 const BATCHSIZE = 200;
157 rpc = new Client({ handler: agent()! });
158 for (let i = 0; i < writes.length; i += BATCHSIZE) {
159 await rpc.post("com.atproto.repo.applyWrites", {
160 input: {
161 repo: agent()!.sub,
162 writes: writes.slice(i, i + BATCHSIZE),
163 },
164 });
165 }
166 const id = addNotification({
167 message: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`,
168 type: "success",
169 });
170 setTimeout(() => removeNotification(id), 3000);
171 setBatchDelete(false);
172 setRecords([]);
173 setCursor(undefined);
174 setOpenDelete(false);
175 setRecreate(false);
176 refetch();
177 };
178
179 const handleSelectionClick = (e: MouseEvent, index: number) => {
180 if (e.shiftKey && lastSelected() !== undefined)
181 setRecords(
182 {
183 from: lastSelected()! < index ? lastSelected() : index + 1,
184 to: index > lastSelected()! ? index - 1 : lastSelected(),
185 },
186 "toDelete",
187 true,
188 );
189 else setLastSelected(index);
190 };
191
192 const selectAll = () =>
193 setRecords(
194 records
195 .map((record, index) =>
196 JSON.stringify(record.record.value).includes(filter() ?? "") ? index : undefined,
197 )
198 .filter((i) => i !== undefined),
199 "toDelete",
200 true,
201 );
202
203 return (
204 <Show when={records.length || response()}>
205 <div class="-mt-2 flex w-full flex-col items-center">
206 <StickyOverlay>
207 <div class="flex w-full flex-col gap-2">
208 <div class="flex items-center gap-1">
209 <Show when={agent() && agent()?.sub === did}>
210 <div class="flex items-center">
211 <Tooltip
212 text={batchDelete() ? "Cancel" : "Delete"}
213 children={
214 <button
215 onclick={() => {
216 setRecords(
217 { from: 0, to: untrack(() => records.length) - 1 },
218 "toDelete",
219 false,
220 );
221 setLastSelected(undefined);
222 setBatchDelete(!batchDelete());
223 }}
224 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
225 >
226 <span
227 class={`iconify text-lg ${batchDelete() ? "lucide--circle-x" : "lucide--trash-2"} `}
228 ></span>
229 </button>
230 }
231 />
232 <Show when={batchDelete()}>
233 <Tooltip
234 text="Select all"
235 children={
236 <button
237 onclick={() => selectAll()}
238 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
239 >
240 <span class="iconify lucide--copy-check text-lg"></span>
241 </button>
242 }
243 />
244 <Tooltip
245 text="Recreate"
246 children={
247 <button
248 onclick={() => {
249 setRecreate(true);
250 setOpenDelete(true);
251 }}
252 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
253 >
254 <span class="iconify lucide--recycle text-lg text-green-500 dark:text-green-400"></span>
255 </button>
256 }
257 />
258 <Tooltip
259 text="Delete"
260 children={
261 <button
262 onclick={() => {
263 setRecreate(false);
264 setOpenDelete(true);
265 }}
266 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
267 >
268 <span class="iconify lucide--trash-2 text-lg text-red-500 dark:text-red-400"></span>
269 </button>
270 }
271 />
272 </Show>
273 </div>
274 <Modal open={openDelete()} onClose={() => setOpenDelete(false)}>
275 <div class="dark:bg-dark-300 dark:shadow-dark-700 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-200 dark:border-neutral-700 starting:opacity-0">
276 <h2 class="mb-2 font-semibold">
277 {recreate() ? "Recreate" : "Delete"}{" "}
278 {records.filter((r) => r.toDelete).length} records?
279 </h2>
280 <div class="flex justify-end gap-2">
281 <Button onClick={() => setOpenDelete(false)}>Cancel</Button>
282 <Button
283 onClick={deleteRecords}
284 class={`dark:shadow-dark-700 rounded-lg px-2 py-1.5 text-xs text-white 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"}`}
285 >
286 {recreate() ? "Recreate" : "Delete"}
287 </Button>
288 </div>
289 </div>
290 </Modal>
291 </Show>
292 <Tooltip text="Jetstream">
293 <A
294 href={`/jetstream?collections=${params.collection}&dids=${params.repo}`}
295 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
296 >
297 <span class="iconify lucide--radio-tower text-lg"></span>
298 </A>
299 </Tooltip>
300 <TextInput
301 name="Filter"
302 placeholder="Filter by substring"
303 onInput={(e) => setFilter(e.currentTarget.value)}
304 class="grow"
305 />
306 </div>
307 <Show when={records.length > 1}>
308 <div class="flex items-center justify-between gap-x-2">
309 <Button
310 onClick={() => {
311 setReverse(!reverse());
312 setRecords([]);
313 setCursor(undefined);
314 refetch();
315 }}
316 >
317 <span
318 class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"}`}
319 ></span>
320 Reverse
321 </Button>
322 <div>
323 <Show when={batchDelete()}>
324 <span>{records.filter((rec) => rec.toDelete).length}</span>
325 <span>/</span>
326 </Show>
327 <span>{filter() ? filteredRecords().length : records.length} records</span>
328 </div>
329 <div class="flex w-20 items-center justify-end">
330 <Show when={cursor()}>
331 <Show when={!response.loading}>
332 <Button onClick={() => refetch()}>Load More</Button>
333 </Show>
334 <Show when={response.loading}>
335 <div class="iconify lucide--loader-circle w-20 animate-spin text-xl" />
336 </Show>
337 </Show>
338 </div>
339 </div>
340 </Show>
341 </div>
342 </StickyOverlay>
343 <div class="flex max-w-full flex-col px-2 font-mono">
344 <For each={filteredRecords()}>
345 {(record, index) => (
346 <>
347 <Show when={batchDelete()}>
348 <label
349 class="flex items-center gap-1 select-none"
350 onclick={(e) => handleSelectionClick(e, index())}
351 >
352 <input
353 type="checkbox"
354 checked={record.toDelete}
355 onchange={(e) => setRecords(index(), "toDelete", e.currentTarget.checked)}
356 />
357 <RecordLink record={record} />
358 </label>
359 </Show>
360 <Show when={!batchDelete()}>
361 <A href={`/at://${did}/${params.collection}/${record.rkey}`}>
362 <RecordLink record={record} />
363 </A>
364 </Show>
365 </>
366 )}
367 </For>
368 </div>
369 </div>
370 </Show>
371 );
372};
373
374export { CollectionView };