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