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