import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto"; import { Client, CredentialManager } from "@atcute/client"; import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons"; import * as TID from "@atcute/tid"; import { A, useParams } from "@solidjs/router"; import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { Button } from "../components/button.jsx"; import { JSONType, JSONValue } from "../components/json.jsx"; import { agent } from "../components/login.jsx"; import { Modal } from "../components/modal.jsx"; import { addNotification, removeNotification } from "../components/notification.jsx"; import { StickyOverlay } from "../components/sticky.jsx"; import { TextInput } from "../components/text-input.jsx"; import Tooltip from "../components/tooltip.jsx"; import { resolvePDS } from "../utils/api.js"; import { localDateFromTimestamp } from "../utils/date.js"; interface AtprotoRecord { rkey: string; cid: string; record: InferXRPCBodyOutput; timestamp: number | undefined; toDelete: boolean; } const LIMIT = 100; const RecordLink = (props: { record: AtprotoRecord }) => { const [hover, setHover] = createSignal(false); const [previewHeight, setPreviewHeight] = createSignal(0); let rkeyRef!: HTMLSpanElement; let previewRef!: HTMLSpanElement; createEffect(() => { if (hover()) setPreviewHeight(previewRef.offsetHeight); }); const isOverflowing = (previewHeight: number) => rkeyRef.offsetTop - window.scrollY + previewHeight + 32 > window.innerHeight; return ( setHover(true)} onmouseleave={() => setHover(false)} > {props.record.rkey} {props.record.cid} {localDateFromTimestamp(props.record.timestamp!)} ); }; const CollectionView = () => { const params = useParams(); const [cursor, setCursor] = createSignal(); const [records, setRecords] = createStore([]); const [filter, setFilter] = createSignal(); const [batchDelete, setBatchDelete] = createSignal(false); const [lastSelected, setLastSelected] = createSignal(); const [reverse, setReverse] = createSignal(false); const [recreate, setRecreate] = createSignal(false); const [openDelete, setOpenDelete] = createSignal(false); const did = params.repo; let pds: string; let rpc: Client; const fetchRecords = async () => { if (!pds) pds = await resolvePDS(did!); if (!rpc) rpc = new Client({ handler: new CredentialManager({ service: pds }) }); const res = await rpc.get("com.atproto.repo.listRecords", { params: { repo: did as ActorIdentifier, collection: params.collection as `${string}.${string}.${string}`, limit: LIMIT, cursor: cursor(), reverse: reverse(), }, }); if (!res.ok) throw new Error(res.data.error); setCursor(res.data.records.length < LIMIT ? undefined : res.data.cursor); const tmpRecords: AtprotoRecord[] = []; res.data.records.forEach((record) => { const rkey = record.uri.split("/").pop()!; tmpRecords.push({ rkey: rkey, cid: record.cid, record: record, timestamp: TID.validate(rkey) ? TID.parse(rkey).timestamp / 1000 : undefined, toDelete: false, }); }); setRecords(records.concat(tmpRecords) ?? tmpRecords); return res.data.records; }; const [response, { refetch }] = createResource(fetchRecords); const filteredRecords = createMemo(() => records.filter((rec) => filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true, ), ); const deleteRecords = async () => { const recsToDel = records.filter((record) => record.toDelete); let writes: Array< | $type.enforce | $type.enforce > = []; recsToDel.forEach((record) => { writes.push({ $type: "com.atproto.repo.applyWrites#delete", collection: params.collection as `${string}.${string}.${string}`, rkey: record.rkey, }); if (recreate()) { writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: params.collection as `${string}.${string}.${string}`, rkey: record.rkey, value: record.record.value, }); } }); const BATCHSIZE = 200; rpc = new Client({ handler: agent()! }); for (let i = 0; i < writes.length; i += BATCHSIZE) { await rpc.post("com.atproto.repo.applyWrites", { input: { repo: agent()!.sub, writes: writes.slice(i, i + BATCHSIZE), }, }); } const id = addNotification({ message: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, type: "success", }); setTimeout(() => removeNotification(id), 3000); setBatchDelete(false); setRecords([]); setCursor(undefined); setOpenDelete(false); setRecreate(false); refetch(); }; const handleSelectionClick = (e: MouseEvent, index: number) => { if (e.shiftKey && lastSelected() !== undefined) setRecords( { from: lastSelected()! < index ? lastSelected() : index + 1, to: index > lastSelected()! ? index - 1 : lastSelected(), }, "toDelete", true, ); else setLastSelected(index); }; const selectAll = () => setRecords( records .map((record, index) => JSON.stringify(record.record.value).includes(filter() ?? "") ? index : undefined, ) .filter((i) => i !== undefined), "toDelete", true, ); return (
{ setRecords({ from: 0, to: records.length - 1 }, "toDelete", false); setLastSelected(undefined); setBatchDelete(!batchDelete()); }} 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" > } /> selectAll()} 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" > } /> { setRecreate(true); setOpenDelete(true); }} 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" > } /> { setRecreate(false); setOpenDelete(true); }} 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" > } />
setOpenDelete(false)}>

{recreate() ? "Recreate" : "Delete"}{" "} {records.filter((r) => r.toDelete).length} records?

setFilter(e.currentTarget.value)} class="grow" />
1}>
{records.filter((rec) => rec.toDelete).length} / {filter() ? filteredRecords().length : records.length} records
{(record, index) => ( <> )}
); }; export { CollectionView };