~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
204 lines · 6.5 KB Raw History
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>
  );
}