chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery } from '@tanstack/react-query';
import { AppWindowIcon, ChevronRightIcon } from 'lucide-react';
import { useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Sparkline } from '#/components/atoms/sparkline.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { SearchField } from '#/components/molecules/search-field.jsx';
import { get, post } from '#/lib/api.js';
import {
appActivitySeries,
combineOffers,
lastActiveAt,
mergeApps,
num,
timeAgo,
} from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';
const SEARCH_THRESHOLD = 6;
export function AppsPage({ data, appHref, onNavigate }) {
const { data: appsData, error } = useQuery({
queryKey: queryKeys.apps,
queryFn: () => get('/apps'),
});
const { data: spacesData } = useQuery({
queryKey: queryKeys.spaces,
queryFn: () => get('/spaces'),
enabled: Boolean(data.features?.spaces),
});
const [query, setQuery] = useState('');
const combine = useApiMutation([queryKeys.apps]);
if (error) {
return <Notice variant="error">{error.message}</Notice>;
}
if (!appsData) return null;
const apps = mergeApps(
data.collections,
appsData.apps,
appsData.aliases,
spacesData?.spaces,
);
const offers = appsData.aliasesAvailable ? combineOffers(apps, appsData) : [];
if (!apps.length) {
return (
<EmptyState
icon={<AppWindowIcon />}
title="No apps yet"
description="An app you sign in to, or that writes records to your account, appears here. Open one to see what it has stored and manage its access."
/>
);
}
const needle = query.trim().toLowerCase();
const matches = needle
? apps.filter(
(app) =>
app.domain.toLowerCase().includes(needle) ||
(app.folded || []).some((domain) => domain.includes(needle)),
)
: apps;
return (
<div className="flex flex-col gap-3">
<ErrorNotice error={combine.error} />
{offers.map((offer) => (
<Notice key={`${offer.from}>${offer.to}`}>
<span className="font-semibold text-foreground">{offer.to}</span> and{' '}
<span className="font-semibold text-foreground">{offer.from}</span>{' '}
look like the same app. One name holds the sign-in and the other holds
its records. Combine them into one entry?
<div className="mt-2.5 flex gap-2">
<Button
size="sm"
disabled={combine.busy}
onClick={() =>
combine.run(() =>
post('/apps/alias', {
action: 'combine',
from: offer.from,
to: offer.to,
}),
)
}
>
Combine
</Button>
<Button
variant="outline"
size="sm"
disabled={combine.busy}
onClick={() =>
combine.run(() =>
post('/apps/alias', {
action: 'dismiss',
from: offer.from,
to: offer.to,
}),
)
}
>
Keep separate
</Button>
</div>
</Notice>
))}
{apps.length > SEARCH_THRESHOLD && (
<SearchField
value={query}
onChange={setQuery}
placeholder="Search apps"
/>
)}
{matches.length === 0 && (
<p className="px-1 py-8 text-center text-[13px] text-muted-foreground">
No apps match "{query.trim()}".
</p>
)}
{matches.map((app) => {
const active = lastActiveAt(app);
// Lead with recency — the app's own page and History carry the record
// detail. Fall back to a count only when nothing datable is known (an
// app whose records all have non-TID rkeys, and no session).
let description;
if (active) {
description = `Active ${timeAgo(active)}`;
} else if (app.count > 0) {
description = `${num(app.count)} record${app.count === 1 ? '' : 's'}`;
} else {
description = 'No records stored';
}
if (app.folded?.length) {
description += ` · includes ${app.folded.join(', ')}`;
}
const series = appActivitySeries(
data.recordActivity,
app.domain,
app.folded,
);
return (
<ListRow
key={app.domain}
className="px-5 py-4"
href={appHref(app.domain)}
onNavigate={onNavigate}
background={series && <Sparkline values={series} />}
media={<AppTile domain={app.domain} label={app.domain} size={44} />}
title={
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-[16px]">
{app.domain}
</span>
{app.sessions.length > 0 && (
<Badge variant="scope" className="shrink-0">
{app.sessions.length} session
{app.sessions.length === 1 ? '' : 's'}
</Badge>
)}
</div>
}
description={description}
actions={
<ChevronRightIcon className="size-4 shrink-0 text-faint" />
}
/>
);
})}
</div>
);
}