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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
284 lines · 9.9 KB Raw History
import { useQuery } from '@tanstack/react-query';
import {
  CircleDotIcon,
  FolderGitIcon,
  GitBranchIcon,
  GitPullRequestIcon,
  LockIcon,
  PlusIcon,
  TagIcon,
} from 'lucide-react';
import { useState } from 'react';
import { Badge } from '#/components/atoms/badge.jsx';
import { ButtonLink } from '#/components/atoms/button-link.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { AccountPanel } from '#/components/molecules/account-panel.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { RepoActivity } from '#/components/molecules/repo-activity.jsx';
import { RepoListSkeleton } from '#/components/molecules/repo-list-skeleton.jsx';
import { SearchField } from '#/components/molecules/search-field.jsx';
import { bytes, timeAgo } from '#/lib/format.js';
import {
  account,
  branchRefs,
  isEmpty,
  loadRepoSummary,
  loadRepos,
  repoList,
  repoSize,
  reposKey,
  tagRefs,
} from '#/lib/git.js';
import { COLUMN } from '#/lib/layout.js';
import { useSession } from '#/lib/session.jsx';
import { cn } from '#/lib/utils.js';

/* The separator belongs to the reading that follows it, so a row that wraps
   never ends a line on a dot. */
const LEAD = "before:mr-2 before:text-faint before:content-['·']";

/** One reading: an icon and a number, with what it counts in the title. */
function Metric({ icon, value, unit, lead }) {
  return (
    <span
      className={cn('flex items-center gap-1.5', lead && LEAD)}
      title={`${value} ${unit}`}
    >
      {icon}
      <span className="font-semibold text-foreground tabular-nums">
        {value}
      </span>
    </span>
  );
}

/** What this repository holds, in the order a reader asks for it. */
function readingsOf(repo) {
  const branches = branchRefs(repo).length;
  const tags = tagRefs(repo).length;
  const readings = [
    {
      key: 'branches',
      icon: <GitBranchIcon className="size-3.5 text-faint" />,
      value: branches,
      unit: branches === 1 ? 'branch' : 'branches',
    },
  ];
  if (tags > 0) {
    readings.push({
      key: 'tags',
      icon: <TagIcon className="size-3.5 text-faint" />,
      value: tags,
      unit: tags === 1 ? 'tag' : 'tags',
    });
  }
  return readings;
}

/** The open pull request and issue counts, once the counts read lands. */
function RepoCounts({ counts }) {
  if (!counts) return null;

  const items = [
    {
      key: 'pulls',
      icon: <GitPullRequestIcon className="size-3.5 text-faint" />,
      value: counts.openPulls,
      unit: counts.openPulls === 1 ? 'open pull request' : 'open pull requests',
    },
    {
      key: 'issues',
      icon: <CircleDotIcon className="size-3.5 text-faint" />,
      value: counts.openIssues,
      unit: counts.openIssues === 1 ? 'open issue' : 'open issues',
    },
  ].filter((item) => item.value > 0);

  return items.map((item) => (
    <Metric
      key={item.key}
      icon={item.icon}
      value={item.value}
      unit={item.unit}
      lead
    />
  ));
}

export function ReposPage() {
  const { session } = useSession();
  const [filter, setFilter] = useState('');
  // The account gate already loaded (or primed) the rows and pushes fresh
  // ones into this key, so the page opens on content and never refetches
  // on its own.
  const { data, error } = useQuery({
    queryKey: [reposKey()],
    queryFn: loadRepos,
    initialData: () => {
      const list = repoList();
      return list.length > 0 ? list : undefined;
    },
    staleTime: Number.POSITIVE_INFINITY,
  });

  // The counts are one read for the whole listing, behind the first paint:
  // the cards draw from the rows already in hand, and the counts fill in when
  // this lands. Each is a discovery pass on the server, so it is asked for
  // here, where the counts show, and nowhere else.
  const { data: summary } = useQuery({
    queryKey: ['repo-summary'],
    queryFn: loadRepoSummary,
    staleTime: Number.POSITIVE_INFINITY,
  });

  // Creating writes the record into this account's own repo, so the button
  // is the owner's. A visitor who is not signed in as the owner cannot.
  const mine = Boolean(session && session.did === account.did);
  const newButton = (
    <ButtonLink href="/new" variant="default">
      <PlusIcon /> New repository
    </ButtonLink>
  );

  if (error) {
    return <PageNotice tone="error">{error.message}</PageNotice>;
  }
  if (!data) {
    return <RepoListSkeleton />;
  }

  // The rows a reader typed for, by name or description. The filter is a
  // field above the list the way a forge's is, and empty it shows everything.
  const shown = filter
    ? data.filter((repo) =>
        `${repo.name} ${repo.description ?? ''}`
          .toLowerCase()
          .includes(filter.toLowerCase()),
      )
    : data;

  return (
    <div className="flex min-h-0 flex-1 flex-col">
      <div className="min-h-0 flex-1 overflow-y-auto">
        <div
          className={cn(
            COLUMN,
            'flex flex-col gap-8 px-(--gutter) py-6 lg:flex-row lg:items-start lg:gap-10',
          )}
        >
          {/* Whose repositories these are, once, beside the list of them. */}
          <aside className="w-full shrink-0 lg:w-72">
            <AccountPanel repos={data} />
          </aside>
          <div className="min-w-0 flex-1">
            {data.length > 0 && (
              <div className="flex items-center gap-2 border-border border-b pb-4">
                <SearchField
                  value={filter}
                  onChange={setFilter}
                  placeholder="Find a repository"
                  className="flex-1"
                />
                {mine && newButton}
              </div>
            )}
            {data.length === 0 ? (
              <EmptyState
                icon={<FolderGitIcon />}
                title="No repositories yet"
                description="Create one, or push one with the git helper and it appears here."
                action={mine && newButton}
              />
            ) : shown.length === 0 ? (
              <EmptyState
                title="Nothing matches"
                description="No repository is called that or says that about itself."
              />
            ) : (
              <ul>
                {shown.map((repo) => {
                  const counts = summary?.get(repo.name);
                  return (
                    <li
                      key={
                        repo.space ? `${repo.space}#${repo.name}` : repo.name
                      }
                      className="flex items-start gap-6 border-border border-b py-6"
                    >
                      <div className="flex min-w-0 flex-1 flex-col gap-2">
                        <div className="flex min-w-0 flex-wrap items-center gap-2">
                          <Link
                            href={`/${encodeURIComponent(repo.name)}`}
                            className="truncate font-semibold text-[20px] leading-tight no-underline hover:underline"
                          >
                            {repo.name}
                          </Link>
                          <Badge className="shrink-0 font-sans">
                            {repo.space ? (
                              <>
                                <LockIcon className="mr-1 size-3" />
                                Private
                              </>
                            ) : (
                              'Public'
                            )}
                          </Badge>
                        </div>
                        {repo.description && (
                          <p className="text-[14px] text-muted-foreground">
                            {repo.description}
                          </p>
                        )}
                        {/* What the repository holds, and how lately it was
                            worked on, on one line the way a forge reads it. */}
                        <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12.5px] text-muted-foreground">
                          {isEmpty(repo) ? (
                            <span className="text-faint">
                              nothing pushed yet
                            </span>
                          ) : (
                            <>
                              {readingsOf(repo).map(
                                ({ key, ...reading }, index) => (
                                  <Metric
                                    key={key}
                                    {...reading}
                                    lead={index > 0}
                                  />
                                ),
                              )}
                              <RepoCounts counts={counts} />
                              <span
                                className={cn(
                                  'font-mono text-[11.5px] text-faint',
                                  LEAD,
                                )}
                              >
                                {bytes(repoSize(repo))}
                              </span>
                            </>
                          )}
                          {repo.updatedAt && (
                            <span className={cn('text-faint', LEAD)}>
                              updated {timeAgo(repo.updatedAt)}
                            </span>
                          )}
                        </div>
                      </div>
                      {/* The gauge stands at the row's end, the way a forge
                          draws a sparkline beside each repository. */}
                      <div className="hidden w-40 shrink-0 self-center sm:block">
                        <RepoActivity repo={repo} dates={counts?.activity} />
                      </div>
                    </li>
                  );
                })}
              </ul>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}