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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
194 lines · 6.6 KB Raw History
import { CheckIcon, CopyIcon, PlusIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Dialog } from '#/components/molecules/dialog.jsx';
import { SelectField } from '#/components/molecules/select-field.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { xrpcPost } from '#/lib/api.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';

/** The shell commands GitHub shows after creating a repository, our spelling. */
function pushInstructions(cloneUrl) {
  return [`git remote add origin ${cloneUrl}`, 'git push -u origin main'].join(
    '\n',
  );
}

export function CommandBlock({ text }) {
  const [copied, setCopied] = useState(false);
  const copy = async () => {
    await navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };
  return (
    <div className="relative rounded-lg border border-border bg-background">
      <pre className="overflow-x-auto px-3 py-2 pr-10 font-mono text-[12px] leading-relaxed">
        {text}
      </pre>
      <button
        type="button"
        aria-label="Copy commands"
        onClick={copy}
        className="absolute top-2 right-2 text-muted-foreground hover:text-foreground"
      >
        {copied ? (
          <CheckIcon className="size-3.5" />
        ) : (
          <CopyIcon className="size-3.5" />
        )}
      </button>
    </div>
  );
}

/**
 * The push commands for a repository nothing has been pushed to. The dialog
 * shows these right after creating, and the repository page keeps showing
 * them until the first push, so closing the dialog loses nothing.
 * @param {{name: string, cloneUrl: string, authority: string, isPrivate: boolean}} props
 */
export function SetupInstructions({ name, cloneUrl, authority, isPrivate }) {
  return (
    <div className="flex flex-col gap-3">
      {isPrivate && (
        <Notice>
          This repository is private. Space members you add can read it; nobody
          else can.
        </Notice>
      )}
      <div className="text-[13px] text-muted-foreground">
        Nothing has been pushed yet. From an existing local repository:
      </div>
      <CommandBlock text={pushInstructions(cloneUrl)} />
      <div className="text-[13px] text-muted-foreground">
        Starting from nothing:
      </div>
      <CommandBlock
        text={[
          `git init -b main ${name}`,
          `cd ${name}`,
          'git add . && git commit -m "initial commit"',
          ...pushInstructions(cloneUrl).split('\n'),
        ].join('\n')}
      />
      <div className="text-[12px] text-faint">
        Pushing needs the helper: npm install -g @pdsjs/git, then
        git-remote-atproto login {authority}.
      </div>
    </div>
  );
}

/**
 * The dialog behind the New repository button. Creating writes an empty
 * repository record at once, private ones into their space, so the list and
 * the repository page exist before the first push.
 * @param {{data: {handle?: string|null, did: string, features: Record<string, boolean>}, onNavigate: (href: string) => void}} props
 */
export function NewRepoDialog({ data, onNavigate }) {
  const authority = data.handle || data.did;
  const canPrivate = Boolean(data.features.spaces && data.features.spaceAdmin);

  const [name, setName] = useState('');
  const [visibility, setVisibility] = useState('public');
  /** @type {[{cloneUrl: string, space: string|null}|null, Function]} */
  const [created, setCreated] = useState(null);
  const action = useApiMutation([
    queryKeys.spaces,
    queryKeys.gitRepos,
    queryKeys.overview,
  ]);

  const validName = /^[A-Za-z0-9._~-]{1,512}$/.test(name);

  const submit = async (event) => {
    event.preventDefault();
    if (!validName) return;
    await action.run(async () => {
      const payload =
        visibility === 'private' ? { name, private: true } : { name };
      const { space } = await xrpcPost('dev.pdsjs.git.createRepo', payload);
      // Public or private, the address is the same: the helper finds the
      // space a private repository keeps under its own name.
      setCreated({ space, cloneUrl: `atproto://${authority}/${name}` });
    });
  };

  const repoHref = created
    ? `/account/git/${encodeURIComponent(name)}${created.space ? `?space=${encodeURIComponent(created.space)}` : ''}`
    : '';

  return (
    <Dialog
      trigger={
        <Button size="sm">
          <PlusIcon /> New repository
        </Button>
      }
      title={created ? name : 'New repository'}
    >
      {created ? (
        <div className="flex flex-col gap-3">
          <SetupInstructions
            name={name}
            cloneUrl={created.cloneUrl}
            authority={authority}
            isPrivate={Boolean(created.space)}
          />
          <div className="text-[12px] text-faint">
            These commands stay on the repository page until the first push.
          </div>
          <Button variant="outline" onClick={() => onNavigate(repoHref)}>
            Go to repository
          </Button>
        </div>
      ) : (
        <form onSubmit={submit} className="flex flex-col gap-4">
          <TextField
            name="name"
            label="Repository name"
            placeholder="my-project"
            value={name}
            onChange={(event) => setName(event.target.value)}
            required
            pattern="[A-Za-z0-9._~\-]{1,512}"
            hint="Letters, digits, and . _ ~ -"
          />
          <SelectField
            name="visibility"
            label="Visibility"
            value={visibility}
            onChange={(value) => setVisibility(value)}
            options={
              canPrivate
                ? [
                    ['public', 'Public'],
                    ['private', 'Private'],
                  ]
                : [['public', 'Public']]
            }
          />
          {!canPrivate && (
            <div className="text-[12px] text-faint">
              Private repositories need spaces enabled on this server.
            </div>
          )}
          {visibility === 'private' && (
            <div className="text-[12px] text-faint">
              Only the people you add can read it. Add them from the repository
              page.
            </div>
          )}
          {action.error && <Notice variant="error">{action.error}</Notice>}
          <Button type="submit" disabled={!validName || action.busy}>
            {visibility === 'private'
              ? 'Create private repository'
              : 'Create repository'}
          </Button>
        </form>
      )}
    </Dialog>
  );
}