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