chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery } from '@tanstack/react-query';
import {
ArrowLeftIcon,
ChevronRightIcon,
ExternalLinkIcon,
} from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button, buttonVariants } from '#/components/atoms/button.jsx';
import { Heading } from '#/components/atoms/heading.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Panel } from '#/components/atoms/panel.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { PublishedCheck } from '#/components/molecules/published-check.jsx';
import { ScopeList } from '#/components/molecules/scope-list.jsx';
import { SearchField } from '#/components/molecules/search-field.jsx';
import { ShowMore } from '#/components/molecules/show-more.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import { get, post } from '#/lib/api.js';
import {
clientHost,
day,
groupByApp,
num,
spacesForApp,
timeAgo,
} from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';
import { formatUserAgent } from '#/lib/userAgent.js';
import { cn } from '#/lib/utils.js';
const RECORD_TYPES_SHOWN = 8;
const SESSIONS_SHOWN = 4;
const COLLECTION_SEARCH_THRESHOLD = 6;
export function AppPage({ data, domain, collectionHref, onNavigate, onBack }) {
const { data: appsData, error } = useQuery({
queryKey: queryKeys.apps,
queryFn: () => get('/apps'),
});
const spacesEnabled = Boolean(data.features?.spaces);
const { data: spacesData } = useQuery({
queryKey: queryKeys.spaces,
queryFn: () => get('/spaces'),
enabled: spacesEnabled,
});
const action = useApiMutation([
queryKeys.apps,
queryKeys.sessions,
queryKeys.overview,
]);
// A row the user folded into another resolves to the row that absorbed it,
// so a stale link or Home card still lands on the combined entry.
const aliases = appsData?.aliases || {};
const shown = aliases[domain] || domain;
const folded = Object.keys(aliases).filter((from) => aliases[from] === shown);
const groups = groupByApp(data.collections);
const own = groups.find((g) => g.domain === shown);
const foldedGroups = groups.filter((g) => folded.includes(g.domain));
// Folded domains that brought no records are client hosts (a subdomain
// sign-in folded into the apex it serves).
const foldedClients = folded.filter(
(d) => !foldedGroups.some((g) => g.domain === d),
);
const collections = [
...(own?.collections || []),
...foldedGroups.flatMap((g) => g.collections),
].sort((a, b) => b.count - a.count);
const total =
(own?.count || 0) + foldedGroups.reduce((sum, g) => sum + g.count, 0);
// Which of these collections' lexicons are published (resolvable) via the
// protocol. Loaded separately from the overview so the list renders instantly
// and the checks fill in as resolution completes.
const collectionNames = collections.map((c) => c.name);
const { data: lexiconStatus } = useQuery({
queryKey: queryKeys.lexiconStatus(collectionNames),
queryFn: () =>
get(
`/lexicon-status?collections=${encodeURIComponent(collectionNames.join(','))}`,
),
enabled: collectionNames.length > 0,
staleTime: 5 * 60_000,
});
const published = lexiconStatus?.published || {};
const rowDomains = [shown, ...folded];
const sessions =
appsData?.apps.filter((app) =>
rowDomains.includes(clientHost(app.clientId)),
) || [];
const spaces = spacesForApp(spacesData?.spaces, rowDomains);
const [showAllRecords, setShowAllRecords] = useState(false);
const [showAllSessions, setShowAllSessions] = useState(false);
const [collectionQuery, setCollectionQuery] = useState('');
const needle = collectionQuery.trim().toLowerCase();
const matchedCollections = needle
? collections.filter((collection) =>
collection.name.toLowerCase().includes(needle),
)
: collections;
const shownCollections = showAllRecords
? matchedCollections
: matchedCollections.slice(0, RECORD_TYPES_SHOWN);
const shownSessions = showAllSessions
? sessions
: sessions.slice(0, SESSIONS_SHOWN);
const revoke = (sessionId) =>
action.run(async () => {
await post('/apps/revoke', { session: sessionId });
});
const revokeAll = () =>
action.run(async () => {
for (const session of sessions) {
if (session.sessionId) {
await post('/apps/revoke', { session: session.sessionId });
}
}
});
const separate = () =>
action.run(async () => {
for (const from of folded) {
await post('/apps/alias', { action: 'separate', from });
}
});
return (
<div className="space-y-5">
<TextLink onClick={onBack} className="gap-1.5">
<ArrowLeftIcon className="size-4" />
Your apps
</TextLink>
<Panel className="flex items-center gap-4">
<AppTile domain={shown} label={shown} size={52} />
<div className="min-w-0">
<div className="truncate font-extrabold text-[22px] text-foreground tracking-tight">
{shown}
</div>
<div className="mt-1 text-[13px] text-muted-foreground">
{num(total)} record{total === 1 ? '' : 's'} across{' '}
{collections.length} collection{collections.length === 1 ? '' : 's'}
</div>
{folded.length > 0 && (
<div className="mt-1 text-[13px] text-muted-foreground">
{[
// A folded record authority reads out as its NSID prefix; a
// folded client host is named as the sign-in it brought.
foldedGroups.length > 0 &&
`Stores records as ${foldedGroups
.map((g) => `${g.domain.split('.').reverse().join('.')}.*`)
.join(', ')}`,
foldedClients.length > 0 &&
`Signs in from ${foldedClients.join(', ')}`,
]
.filter(Boolean)
.join(' · ')}{' '}
·{' '}
<TextLink
disabled={action.busy}
onClick={separate}
className="text-[12.5px]"
>
Separate
</TextLink>
</div>
)}
</div>
<div className="flex-1" />
<Link
href={`https://${shown}`}
external
className="flex shrink-0 items-center gap-1.5 font-bold text-[13px] text-key transition-colors hover:text-foreground"
title={`Open ${shown}`}
>
Visit
<ExternalLinkIcon className="size-4" />
</Link>
</Panel>
<div className="space-y-5">
<Panel>
<Heading>Access</Heading>
<ErrorNotice error={error || action.error} className="mt-3" />
{sessions.length ? (
<>
<div className="mt-3 flex items-baseline gap-3">
<p className="text-[13px] text-muted-foreground leading-relaxed">
Signed in on {sessions.length} session
{sessions.length === 1 ? '' : 's'} right now.
</p>
<div className="flex-1" />
{sessions.length > 1 && (
<TextLink
tone="danger"
disabled={action.busy}
onClick={revokeAll}
className="text-[12.5px]"
>
Revoke all
</TextLink>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{shownSessions.map((session, index) => (
<div
key={session.sessionId || index}
className="rounded-2xl border border-border bg-muted p-4"
>
<ScopeList scope={session.scope} />
{formatUserAgent(session.userAgent) && (
<div className="mt-2 text-[12.5px] text-foreground">
{formatUserAgent(session.userAgent)}
</div>
)}
<div className="mt-3 flex items-center gap-3">
<div className="text-[12px] text-faint">
{session.authorizedAt
? `Authorized ${day(session.authorizedAt)}`
: 'Authorized'}
{session.lastAccessedAt && (
<span className="block">
Last active {timeAgo(session.lastAccessedAt)}
</span>
)}
</div>
<div className="flex-1" />
{session.sessionId && (
<Button
variant="outline"
size="sm"
disabled={action.busy}
onClick={() => revoke(session.sessionId)}
>
Revoke
</Button>
)}
</div>
</div>
))}
</div>
<ShowMore
total={sessions.length}
cap={SESSIONS_SHOWN}
expanded={showAllSessions}
onToggle={() => setShowAllSessions((v) => !v)}
noun="sessions"
/>
</>
) : (
<p className="mt-3 text-[13px] text-muted-foreground leading-relaxed">
No app is signed in under this name right now. Its records stay in
your account whether or not it has access.
</p>
)}
</Panel>
<Panel>
<Heading className="mb-4">Records</Heading>
{collections.length ? (
<>
{collections.length > COLLECTION_SEARCH_THRESHOLD && (
<SearchField
value={collectionQuery}
onChange={setCollectionQuery}
placeholder="Search collections"
className="mb-3"
/>
)}
{matchedCollections.length === 0 ? (
<p className="py-6 text-center text-[13px] text-muted-foreground">
No collections match "{collectionQuery.trim()}".
</p>
) : (
<>
<div className="flex flex-col gap-2">
{shownCollections.map((collection) => (
<ListRow
key={collection.name}
href={collectionHref(collection.name)}
onNavigate={onNavigate}
title={
<>
<span className="font-semibold text-[14px]">
{collection.name}
</span>
{/* Always a boolean: an undefined prop would make
each row resolve its own NSID, stampeding the
batch request for the same answers. */}
<PublishedCheck
nsid={collection.name}
published={Boolean(published[collection.name])}
/>
</>
}
description={`${num(collection.count)} record${collection.count === 1 ? '' : 's'}`}
actions={
<ChevronRightIcon className="size-4 text-faint" />
}
/>
))}
</div>
<ShowMore
total={matchedCollections.length}
cap={RECORD_TYPES_SHOWN}
expanded={showAllRecords}
onToggle={() => setShowAllRecords((v) => !v)}
noun="collections"
/>
</>
)}
</>
) : (
<p className="text-[13px] text-muted-foreground">
This app holds access but has not stored anything yet.
</p>
)}
</Panel>
</div>
{spaces.length > 0 && (
<Panel>
<Heading>Shared spaces</Heading>
<p className="mt-2 mb-4 text-[13px] text-muted-foreground leading-relaxed">
Permissioned data this app manages. The counts are what you have
written. Other members' records live on their own servers.
</p>
<div className="space-y-3">
{spaces.map((space) => (
<div
key={space.uri}
className="rounded-2xl border border-border bg-muted p-4"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[13px] text-foreground">
{space.spaceType}
</span>
{space.isOwner && <Badge>yours</Badge>}
{space.deleted && <Badge>deleted</Badge>}
</div>
<div className="mt-1 text-[12px] text-faint">
{space.members} member{space.members === 1 ? '' : 's'} ·{' '}
{space.policy}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{space.collections.map((collection) => (
<Link
key={collection.collection}
href={collectionHref(collection.collection, space.uri)}
onNavigate={onNavigate}
className={cn(
buttonVariants({ variant: 'secondary', size: 'sm' }),
'h-7 font-mono text-xs',
)}
>
{collection.collection} ({num(collection.count)})
</Link>
))}
</div>
</div>
))}
</div>
</Panel>
)}
</div>
);
}