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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
176 lines · 5.7 KB Raw History
import { useQuery } from '@tanstack/react-query';
import { CheckIcon, ContainerIcon, CopyIcon } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '#/components/atoms/badge.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { RowIcon } from '#/components/atoms/row-icon.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import { get } from '#/lib/api.js';
import { bytes, timeAgo } from '#/lib/format.js';
import { queryKeys } from '#/lib/query.js';

/** One line summarizing an image repository's shape. */
function repoSummary(repo) {
  if (repo.manifests === 0) return 'nothing pushed yet';
  const parts = [
    `${repo.tags.length} ${repo.tags.length === 1 ? 'tag' : 'tags'}`,
  ];
  parts.push(bytes(repo.size));
  if (repo.updatedAt) parts.push(`pushed ${timeAgo(repo.updatedAt)}`);
  return parts.join(' · ');
}

/** A command with a copy button beside it, shown in full where room allows. */
function CopyCommand({ command, compact }) {
  const [copied, setCopied] = useState(false);

  const copy = (event) => {
    event.preventDefault();
    event.stopPropagation();
    navigator.clipboard.writeText(command).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    });
  };

  return (
    <button
      type="button"
      onClick={copy}
      title={`Copy: ${command}`}
      className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-background px-2 py-1 font-mono text-[12px] text-muted-foreground hover:text-foreground"
    >
      {!compact && <span className="hidden sm:inline">{command}</span>}
      {copied ? (
        <CheckIcon className="size-3.5" />
      ) : (
        <CopyIcon className="size-3.5" />
      )}
    </button>
  );
}

/** Every tag of one image: name, manifest digest, and its pull command. */
function TagList({ repo }) {
  return (
    <div className="max-h-72 overflow-y-auto rounded-2xl border border-border bg-card px-5 py-2">
      {repo.tags.map((tag) => (
        <div
          key={tag.tag}
          className="flex items-center gap-3 border-border border-b py-2 text-[12.5px] last:border-b-0"
        >
          <span className="min-w-0 shrink-0 font-mono">{tag.tag}</span>
          <span
            title={tag.digest}
            className="min-w-0 flex-1 truncate font-mono text-muted-foreground"
          >
            {tag.digest}
          </span>
          <CopyCommand
            command={`docker pull ${repo.pullRef}:${tag.tag}`}
            compact
          />
        </div>
      ))}
    </div>
  );
}

/**
 * @param {{data: Object}} props
 */
export function RegistryPage({ data: account }) {
  const [openTags, setOpenTags] = useState(/** @type {string|null} */ (null));
  const { data, error } = useQuery({
    queryKey: queryKeys.ociRepos,
    queryFn: () => get('/oci/repos'),
  });

  if (error) {
    return <Notice variant="error">{error.message}</Notice>;
  }
  if (!data) return null;

  if (!data.repos.length) {
    return (
      <EmptyState
        icon={<ContainerIcon />}
        title="No images yet"
        description={
          <>
            Your account is a container registry. Sign in with{' '}
            <span className="font-mono text-[12px]">
              docker login {account.hostname} -u {account.handle}
            </span>{' '}
            and an app password, then push to{' '}
            <span className="font-mono text-[12px]">
              {account.hostname}/image-name:tag
            </span>
            . Layers are stored in your repo as blobs, so images ride along in
            backups and migrate with your account. Anyone can pull.
          </>
        }
      />
    );
  }

  return (
    <div className="flex flex-col gap-3">
      {data.repos.map((repo) => {
        const latest =
          repo.tags.find((t) => t.tag === 'latest') ?? repo.tags[0];
        return (
          <div key={repo.name} className="flex flex-col gap-2">
            <ListRow
              className="px-5 py-4"
              media={
                <RowIcon>
                  <ContainerIcon />
                </RowIcon>
              }
              title={
                <div className="flex min-w-0 items-center gap-2">
                  <span className="min-w-0 truncate text-[16px]">
                    {repo.name}
                  </span>
                  {repo.tags.slice(0, 4).map((tag) => (
                    <Badge variant="scope" className="shrink-0" key={tag.tag}>
                      {tag.tag}
                    </Badge>
                  ))}
                  {repo.tags.length > 4 && (
                    <Badge variant="scope" className="shrink-0">
                      +{repo.tags.length - 4}
                    </Badge>
                  )}
                </div>
              }
              description={repoSummary(repo)}
              actions={
                <div className="flex shrink-0 items-center gap-3">
                  {repo.tags.length > 0 && (
                    <TextLink
                      onClick={() =>
                        setOpenTags(openTags === repo.name ? null : repo.name)
                      }
                    >
                      {openTags === repo.name ? 'Hide tags' : 'Tags'}
                    </TextLink>
                  )}
                  {latest && (
                    <CopyCommand
                      command={`docker pull ${repo.pullRef}:${latest.tag}`}
                    />
                  )}
                </div>
              }
            />
            {openTags === repo.name && <TagList repo={repo} />}
          </div>
        );
      })}
    </div>
  );
}