chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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>
);
}