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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
160 lines · 5.0 KB Raw History
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus } 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 { JsonView } from '#/components/molecules/json-view.jsx';
import { RepoScope } from '#/components/molecules/repo-scope.jsx';
import { ago, rkeyTime } from '#/lib/format.js';
import { href } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import { createRecord, listRecords } from '#/lib/spaces.js';

/** The starting document for a new record, so a writer edits rather than types. */
function template(collection) {
  return JSON.stringify(
    { $type: collection, createdAt: new Date().toISOString() },
    null,
    2,
  );
}

function NewRecord({ space, collection, onCreated }) {
  const { session } = useSession();
  const [open, setOpen] = useState(false);
  const [text, setText] = useState(() => template(collection));
  const [rkey, setRkey] = useState('');

  const create = useMutation({
    mutationFn: () =>
      createRecord(session, {
        space,
        collection,
        rkey: rkey || undefined,
        record: JSON.parse(text),
      }),
    onSuccess: () => {
      setOpen(false);
      setText(template(collection));
      setRkey('');
      onCreated();
    },
  });

  if (!open) {
    return (
      <Button variant="dashed" size="surface" onClick={() => setOpen(true)}>
        <span className="flex items-center justify-center gap-2 py-3 text-sm">
          <Plus className="size-4" />
          New record
        </span>
      </Button>
    );
  }

  return (
    <Card className="flex flex-col gap-3 p-4">
      <input
        value={rkey}
        onChange={(event) => setRkey(event.target.value)}
        placeholder="record key, left blank for a timestamp"
        className="h-9 rounded-lg border border-input bg-transparent px-3 font-mono text-[12.5px] text-foreground outline-none focus-visible:border-ring"
      />
      <textarea
        value={text}
        onChange={(event) => setText(event.target.value)}
        spellCheck="false"
        rows={12}
        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"
      />
      {create.error && <Notice variant="error">{create.error.message}</Notice>}
      <div className="flex gap-2">
        <Button onClick={() => create.mutate()} disabled={create.isPending}>
          {create.isPending ? 'Writing…' : 'Create'}
        </Button>
        <Button variant="ghost" onClick={() => setOpen(false)}>
          Cancel
        </Button>
      </div>
    </Card>
  );
}

export function RecordsPage({ space, collection, repo }) {
  const { session, writable } = useSession();
  const client = useQueryClient();
  const own = !repo || repo === session.did;

  const { data, error, isPending } = useQuery({
    queryKey: ['records', space, repo, collection],
    queryFn: () =>
      listRecords(session, { space, repo }, { collection, reverse: true }),
    retry: false,
  });

  return (
    <div className="flex flex-col gap-4">
      <div>
        <Link
          href={href('/space', { uri: space, repo })}
          className="text-[13px]"
        >
          ← Back to the space
        </Link>
        <h1 className="mt-2 font-semibold font-mono text-[16px] text-foreground">
          {collection}
        </h1>
      </div>

      <RepoScope space={space} repo={repo} />

      {isPending && <Skeleton className="h-40" />}
      {error && <Notice variant="error">{error.message}</Notice>}

      {data?.records.map((record) => {
        const written = rkeyTime(record.rkey);
        return (
          <Card key={record.rkey} className="p-4">
            <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
              <Link
                href={href('/record', {
                  uri: space,
                  collection,
                  rkey: record.rkey,
                  repo,
                })}
                className="font-mono text-[13px]"
              >
                {record.rkey}
              </Link>
              {written && (
                <span className="text-faint text-xs">{ago(written)}</span>
              )}
            </div>
            {record.value && <JsonView value={record.value} className="mt-3" />}
          </Card>
        );
      })}

      {data?.cursor && (
        <Notice>
          Showing the first {data.records.length} records. Older ones are behind
          a cursor this page does not follow yet.
        </Notice>
      )}

      {writable && own && (
        <NewRecord
          space={space}
          collection={collection}
          onCreated={() =>
            client.invalidateQueries({ queryKey: ['records', space] })
          }
        />
      )}
    </div>
  );
}