chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { AtUri } from '#/components/molecules/at-uri.jsx';
import { Copyable } from '#/components/molecules/copyable.jsx';
import { JsonView } from '#/components/molecules/json-view.jsx';
import { RepoScope } from '#/components/molecules/repo-scope.jsx';
import { ago, fileSize, rkeyTime } from '#/lib/format.js';
import { href } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import { deleteRecord, getBlob, getRecord, putRecord } from '#/lib/spaces.js';
/**
* One blob a record references. The PDS serves it with a disposition that stops
* it from being displayed in the PDS's own origin, so the bytes are read here
* and shown from an object URL belonging to this page.
*/
function BlobPreview({ space, repo, cid, onClose }) {
const { session } = useSession();
const { data, error, isPending } = useQuery({
queryKey: ['blob', space, repo, cid],
queryFn: () => getBlob(session, { space, repo }, cid),
retry: false,
});
return (
<Card className="flex flex-col gap-3 p-4">
<div className="flex items-baseline justify-between gap-3">
<Copyable value={cid} className="text-[12px]">
{cid.slice(0, 20)}…
</Copyable>
<Button variant="ghost" size="xs" onClick={onClose}>
Close
</Button>
</div>
{isPending && <Skeleton className="h-40" />}
{error && <Notice variant="error">{error.message}</Notice>}
{data && (
<>
{data.type.startsWith('image/') ? (
<img
src={data.url}
alt={cid}
className="max-h-96 rounded-xl object-contain"
/>
) : (
<div className="text-muted-foreground text-sm">
{data.type || 'unknown type'}
</div>
)}
<a
href={data.url}
download={cid}
className="text-[13px] text-key hover:underline"
>
Download {fileSize(data.size)}
</a>
</>
)}
</Card>
);
}
export function RecordPage({ space, collection, rkey, repo }) {
const { session, writable } = useSession();
const client = useQueryClient();
const own = !repo || repo === session.did;
const [blobCid, setBlobCid] = useState(null);
const [draft, setDraft] = useState(null);
const { data, error, isPending } = useQuery({
queryKey: ['record', space, repo, collection, rkey],
queryFn: () => getRecord(session, { space, repo }, collection, rkey),
retry: false,
});
const save = useMutation({
mutationFn: () =>
putRecord(session, {
space,
collection,
rkey,
record: JSON.parse(draft),
}),
onSuccess: () => {
setDraft(null);
client.invalidateQueries({ queryKey: ['record', space] });
},
});
const remove = useMutation({
mutationFn: () => deleteRecord(session, { space, collection, rkey }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['records', space] });
window.history.back();
},
});
const written = rkeyTime(rkey);
return (
<div className="flex flex-col gap-4">
<div>
<Link
href={href('/records', { uri: space, collection, repo })}
className="text-[13px]"
>
← {collection}
</Link>
<h1 className="mt-2 font-mono font-semibold text-[16px] text-foreground">
{rkey}
</h1>
{written && (
<div className="mt-1 text-faint text-xs">written {ago(written)}</div>
)}
</div>
<RepoScope space={space} repo={repo} />
{isPending && <Skeleton className="h-56" />}
{error && <Notice variant="error">{error.message}</Notice>}
{data && (
<>
<Card className="flex flex-col gap-1 px-4 py-3">
<AtUri uri={data.uri} className="text-[11px]" />
<Copyable value={data.cid} className="max-w-full text-[11px]" />
</Card>
{blobCid && (
<BlobPreview
space={space}
repo={repo}
cid={blobCid}
onClose={() => setBlobCid(null)}
/>
)}
{draft === null ? (
<Card className="p-4">
<JsonView value={data.value} onBlob={setBlobCid} />
</Card>
) : (
<Card className="flex flex-col gap-3 p-4">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
spellCheck="false"
rows={18}
className="rounded-lg border border-input bg-transparent p-3 font-mono text-[12.5px] text-foreground leading-[1.6] outline-none focus-visible:border-ring"
/>
{save.error && (
<Notice variant="error">{save.error.message}</Notice>
)}
<div className="flex gap-2">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending ? 'Writing…' : 'Save'}
</Button>
<Button variant="ghost" onClick={() => setDraft(null)}>
Cancel
</Button>
</div>
</Card>
)}
{writable && own && draft === null && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDraft(JSON.stringify(data.value, null, 2))}
>
<Pencil />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => {
if (window.confirm(`Delete ${collection}/${rkey}?`)) {
remove.mutate();
}
}}
>
<Trash2 />
Delete
</Button>
</div>
)}
{remove.error && (
<Notice variant="error">{remove.error.message}</Notice>
)}
</>
)}
</div>
);
}