chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Outlet, useRouterState } from '@tanstack/react-router';
import { LockIcon } from 'lucide-react';
import { Avatar } from '#/components/atoms/avatar.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { RepoListSkeleton } from '#/components/molecules/repo-list-skeleton.jsx';
import { RepoPageSkeleton } from '#/components/molecules/repo-page-skeleton.jsx';
import { RepoTabs } from '#/components/molecules/repo-tabs.jsx';
import { SessionControls } from '#/components/molecules/session-controls.jsx';
import { ThemeMenu } from '#/components/molecules/theme-menu.jsx';
import { loadAccount, primeOnce } from '#/lib/account.js';
import { initial, loadAuthors, owner } from '#/lib/authors.js';
import { identicon } from '#/lib/avatars.js';
import {
account as accountInfo,
loadCapabilities,
repoRecord,
} from '#/lib/git.js';
import { useSpace } from '#/lib/navigation.jsx';
import { loadThemes } from '#/lib/theme.js';
import { cn } from '#/lib/utils.js';
/**
* Which section of a repository a path names. The tab bar marks it, and each
* page belongs to exactly one: a commit is part of the history, a run is part
* of the checks, a file is part of the code.
* @param {string|undefined} kind - the path segment after the repository
* @returns {string}
*/
function sectionOf(kind) {
if (kind === 'commits' || kind === 'commit') return 'commits';
if (kind === 'checks') return 'checks';
if (kind === 'packages') return 'packages';
if (kind === 'pulls' || kind === 'pull') return 'pulls';
if (kind === 'issues') return 'issues';
if (kind === 'settings') return 'settings';
return 'code';
}
/**
* The shell: the bar, the repository's sections, and the account gate every
* screen waits on. The screen itself is the route's, rendered where the
* shell leaves room for it.
*/
export function App() {
const path = useRouterState({ select: (state) => state.location.pathname });
const queryClient = useQueryClient();
const segments = path.split('/').filter(Boolean).map(decodeURIComponent);
const space = useSpace();
// The previous visit's account and repository rows, read synchronously.
// With them in hand every screen renders real content on the first frame,
// and the fresh reads below correct it in place.
const primed = primeOnce();
// Discovery names the account and its PDS, and the repository list has to
// be in hand before any screen renders: every route below the list reads
// its refs out of that record.
const { data: account, error } = useQuery({
queryKey: ['account'],
queryFn: () => loadAccount(queryClient),
staleTime: Number.POSITIVE_INFINITY,
});
const ready = Boolean(account) || Boolean(primed);
// The repository in view, and which of its sections. Both come from the
// path, so every page carries the same heading and the same tabs without
// passing them down.
const repo = ready && segments.length > 0 ? segments[0] : '';
const known = Boolean(repo) && Boolean(repoRecord(repo, space));
const section = sectionOf(segments[1]);
// The capability probes decide which clone URLs and which file links a
// screen offers. That decorates a page rather than filling it, so the
// probes run behind the first paint; the answers appear through this
// query's re-render. The probe asks about a repository, so the list
// comes first.
useQuery({
queryKey: ['capabilities'],
enabled: ready,
queryFn: loadCapabilities,
staleTime: Number.POSITIVE_INFINITY,
});
// The palettes the account publishes, added to the ones the page ships.
// A theme is decoration, so this runs beside the screen; the remembered
// choice is already painted below.
useQuery({
queryKey: ['themes'],
enabled: ready,
queryFn: loadThemes,
staleTime: Number.POSITIVE_INFINITY,
});
// Which atproto identity each commit author is, where the account claims
// one. This runs beside the screen rather than before it; the pages that
// show authors wait for it themselves, and the last visit's answer is
// already in hand, so a face is on the first frame either way.
useQuery({
queryKey: ['authors'],
enabled: ready && known,
queryFn: loadAuthors,
staleTime: Number.POSITIVE_INFINITY,
});
return (
<>
{/* The window is the app: the bar holds its place and each pane below
takes its own scroll, so nothing but the reading area ever moves. */}
<div className="flex h-dvh flex-col overflow-hidden">
{/* Where the reader is, and nothing else. Which section is open and
what that section is looking at each get a band of their own
below, so no one bar carries three different jobs. */}
<header
className={cn(
'shrink-0 bg-muted',
// The sections band below carries the rule when there is one.
!known && 'border-border border-b',
)}
>
<div className="flex h-14 items-center gap-3 px-(--gutter)">
{ready && (
<Link
href="/"
title={accountInfo.handle}
className="flex shrink-0"
>
<Avatar
src={owner()?.avatar || identicon(accountInfo.did)}
fallback={
<span className="text-[10px] text-faint">
{initial(owner()?.displayName || accountInfo.handle)}
</span>
}
className="size-7"
/>
</Link>
)}
<nav className="flex min-w-0 shrink items-center gap-2 text-[15px]">
{/* On a narrow window the repository's own name is the one that
has to survive. The avatar carries the way back to the list
at every width. */}
<Link
href="/"
className={cn(
'truncate no-underline hover:underline',
known
? 'hidden text-muted-foreground sm:inline'
: 'font-semibold text-foreground',
)}
>
{accountInfo.handle || 'Repositories'}
</Link>
{known && (
<>
<span className="hidden text-faint sm:inline">/</span>
<Link
href={`/${encodeURIComponent(repo)}`}
className="flex min-w-0 items-center gap-1.5 font-semibold text-foreground no-underline hover:underline"
>
{space && (
<LockIcon
className="size-3.5 shrink-0 text-faint"
aria-label="Private"
/>
)}
<span className="truncate">{repo}</span>
</Link>
</>
)}
</nav>
<div className="ml-auto flex shrink-0 items-center gap-2 pl-2">
<SessionControls />
<span className="h-4 w-px bg-border" aria-hidden="true" />
<ThemeMenu />
</div>
</div>
</header>
{/* The repository's sections, with the one action that belongs to the
repository rather than to any one of them. */}
{known && (
<div className="shrink-0 border-border border-b bg-muted">
<div className="flex items-end gap-3 px-(--gutter)">
<RepoTabs repo={repo} active={section} space={space} />
</div>
</div>
)}
{/* Every screen holds the window and scrolls inside itself, so the
bar and whatever each page fixes above its list stay put. */}
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
{path === '/callback' ? (
<Outlet />
) : error && !ready ? (
<PageNotice tone="error">
Could not reach the PDS. {error.message}
</PageNotice>
) : !ready ? (
// The URL says which screen is coming, so the placeholder can hold
// its shape rather than a bare block.
segments.length === 0 ? (
<RepoListSkeleton />
) : (
<RepoPageSkeleton />
)
) : (
<Outlet />
)}
</main>
</div>
</>
);
}