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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
210 lines · 7.9 KB Raw History
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { GlobeIcon, LockIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Input } from '#/components/atoms/input.jsx';
import { Label } from '#/components/atoms/label.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { createRepo } from '#/lib/create.js';
import { account, loadRepos } from '#/lib/git.js';
import { useNavigate } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import { spaceSupport } from '#/lib/spaceSupport.js';
import { cn } from '#/lib/utils.js';

/** The characters a repository name may hold, from the server's own rule. */
const NAME = /^[A-Za-z0-9._~-]{1,512}$/;

/** The two visibilities, each with the sentence that says what it means. */
const VISIBILITIES = [
  {
    value: 'public',
    label: 'Public',
    Icon: GlobeIcon,
    says: 'Anyone on the network can read it.',
  },
  {
    value: 'private',
    label: 'Private',
    Icon: LockIcon,
    says: 'Only people you add can read it.',
  },
];

/**
 * Creating a repository, on a page of its own.
 *
 * The forge is one account's, so the owner is the account and the form only
 * asks for the name, a description and the visibility. A private repository
 * lives in a permissioned space, which the account's own PDS has to serve;
 * the form offers one only when it does. The space itself needs nothing: one
 * is created for the repository, keyed by its name, and members join from the
 * repository page.
 */
export function NewRepoPage() {
  const { session } = useSession();
  const queryClient = useQueryClient();
  const navigate = useNavigate();
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [visibility, setVisibility] = useState('public');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  // Whether the account's PDS serves the spaces a private repository needs.
  const { data: supported } = useQuery({
    queryKey: ['space-support'],
    queryFn: spaceSupport,
    staleTime: Number.POSITIVE_INFINITY,
  });

  const mine = Boolean(session && session.did === account.did);
  const validName = NAME.test(name) && name !== '.' && name !== '..';
  const submit = async (event) => {
    event.preventDefault();
    if (!validName || !mine) return;
    setBusy(true);
    setError('');
    try {
      const payload =
        visibility === 'private'
          ? { name, description, private: true }
          : { name, description };
      await createRepo(
        /** @type {import('#/lib/oauth.js').Session} */ (session),
        payload,
      );
      // The record the new page reads from is in hand before the page opens,
      // the way a merge reloads before the reads that hang off it are.
      await loadRepos();
      await queryClient.invalidateQueries();
      navigate(`/${encodeURIComponent(name)}`);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
      setBusy(false);
    }
  };

  return (
    <Screen>
      {/* A form stands on its own, centred, the way a forge's does: nothing
          on this page but the question it asks. */}
      <div className="mx-auto w-full max-w-2xl px-(--gutter) py-8">
        <h2 className="font-semibold text-[20px] leading-tight tracking-[-0.01em]">
          Create a new repository
        </h2>
        <p className="mt-1.5 text-[13.5px] text-muted-foreground">
          A repository is a record in {account.handle}'s own repo, with its
          bundles in blob storage. Push to it with the git helper once it
          exists.
        </p>

        {!mine ? (
          <p className="mt-6 text-[13.5px] text-muted-foreground">
            Creating writes the record into {account.handle}'s own repo, so it
            needs this account's sign-in rather than permission from anybody
            else. Sign in as {account.handle} from the bar above.
          </p>
        ) : (
          <form onSubmit={submit} className="mt-6 flex flex-col gap-5">
            <div>
              <Label htmlFor="repo-name">Repository name</Label>
              <div className="flex items-center gap-2">
                <span className="shrink-0 text-[14px] text-faint">
                  {account.handle} /
                </span>
                <Input
                  id="repo-name"
                  value={name}
                  onChange={(event) => setName(event.target.value)}
                  placeholder="my-project"
                  pattern="[A-Za-z0-9._~\-]{1,512}"
                  autoFocus
                  disabled={busy}
                />
              </div>
              {name && (
                <p className="mt-1.5 font-mono text-[12px] text-faint">
                  atproto://{account.handle}/{name}
                </p>
              )}
            </div>

            <div>
              <Label htmlFor="repo-description">
                Description <span className="text-faint">(optional)</span>
              </Label>
              <Textarea
                id="repo-description"
                value={description}
                onChange={(event) => setDescription(event.target.value)}
                placeholder="What this repository is for"
                rows={2}
                maxLength={3000}
                className="resize-none"
                disabled={busy}
              />
            </div>

            <fieldset className="flex flex-col gap-2">
              <legend className="mb-1.5 font-medium text-[12.5px] text-muted-foreground">
                Visibility
              </legend>
              <div className="grid gap-2 sm:grid-cols-2">
                {VISIBILITIES.map((option) => (
                  <button
                    key={option.value}
                    type="button"
                    onClick={() => setVisibility(option.value)}
                    aria-pressed={visibility === option.value}
                    className={cn(
                      'flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors',
                      visibility === option.value
                        ? 'border-ring bg-accent'
                        : 'border-input bg-background hover:bg-accent',
                    )}
                  >
                    <option.Icon className="mt-0.5 size-4 shrink-0 text-faint" />
                    <span className="min-w-0">
                      <span className="block font-semibold text-[13.5px]">
                        {option.label}
                      </span>
                      <span className="mt-0.5 block text-[12px] text-faint">
                        {option.says}
                      </span>
                    </span>
                  </button>
                ))}
              </div>
              {visibility === 'private' &&
                (supported === undefined ? null : supported ? (
                  <p className="mt-1 text-[12.5px] text-faint">
                    Only you can read it until you add people, from the
                    repository's settings after the first push.
                  </p>
                ) : (
                  <p className="mt-1 text-[12.5px] text-faint">
                    This server cannot host private repositories yet, so only
                    public ones can be created here.
                  </p>
                ))}
            </fieldset>

            {error && <p className="text-[12.5px] text-destructive">{error}</p>}

            <div className="flex items-center gap-3">
              <Button type="submit" disabled={busy || !validName}>
                {busy
                  ? 'Creating…'
                  : visibility === 'private'
                    ? 'Create private repository'
                    : 'Create repository'}
              </Button>
            </div>
          </form>
        )}
      </div>
    </Screen>
  );
}