chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Notice } from '#/components/atoms/notice.jsx';
import { PublishedCheck } from '#/components/molecules/published-check.jsx';
import { AppSkeleton } from '#/components/organisms/app-skeleton.jsx';
import { Shell } from '#/components/organisms/shell.jsx';
import { get } from '#/lib/api.js';
import { nsidDomain } from '#/lib/format.js';
import { queryKeys } from '#/lib/query.js';
import { useRouter } from '#/lib/router.js';
import { applyTheme } from '#/lib/theme.js';
import { AppPage } from '#/pages/app-page.jsx';
import { AppsPage } from '#/pages/apps.jsx';
import { BackupsPage } from '#/pages/backups.jsx';
import { DrivePage } from '#/pages/drive.jsx';
import { FilesPage } from '#/pages/files.jsx';
import { GitPage } from '#/pages/git.jsx';
import { GitRepoPage } from '#/pages/git-repo-page.jsx';
import { HistoryPage } from '#/pages/history.jsx';
import { HomePage } from '#/pages/home.jsx';
import { IdentityPage } from '#/pages/identity.jsx';
import { PackagesPage } from '#/pages/packages.jsx';
import { RecordsPage } from '#/pages/records.jsx';
import { RegistryPage } from '#/pages/registry.jsx';
import { SecurityPage } from '#/pages/security.jsx';
import { SettingsPage } from '#/pages/settings.jsx';
import { SitesPage } from '#/pages/sites.jsx';
const APPS_PATH = '/account/apps';
const RECORDS_PATH = '/account/repo/records';
const FILES_PATH = '/account/files';
const SITES_PATH = '/account/sites';
const GIT_PATH = '/account/git';
const DRIVE_PATH = '/account/drive';
const REGISTRY_PATH = '/account/registry';
const PACKAGES_PATH = '/account/packages';
/** The account sections, in rail order. Counts come from the overview.
* Sites appears only when the server composes the feature in, under a Labs
* heading at the end of the rail. */
function sectionsFor(data) {
return [
{ path: '/account', title: 'Home' },
{ path: APPS_PATH, title: 'Your apps', count: data.stats.apps },
...(data.features.files
? [{ path: FILES_PATH, title: 'Files', count: data.stats.blobs }]
: []),
{ path: '/account/history', title: 'History' },
{ path: '/account/backups', title: 'Backups' },
{ path: '/account/security', title: 'Sign-in & security' },
{ path: '/account/identity', title: 'Username & host' },
{ path: '/account/settings', title: 'Settings' },
...(data.features.sites ||
data.features.git ||
data.features.drive ||
data.features.oci ||
data.features.npm
? [{ heading: 'Labs' }]
: []),
...(data.features.sites ? [{ path: SITES_PATH, title: 'Sites' }] : []),
...(data.features.git
? [
{
path: GIT_PATH,
title: 'Repositories',
count: data.stats.gitRepos,
},
]
: []),
...(data.features.drive
? [
{
path: DRIVE_PATH,
title: 'Drive',
count: data.stats.driveFiles,
},
]
: []),
...(data.features.oci
? [
{
path: REGISTRY_PATH,
title: 'Registry',
count: data.stats.ociRepos,
},
]
: []),
...(data.features.npm
? [
{
path: PACKAGES_PATH,
title: 'Packages',
count: data.stats.npmPackages,
},
]
: []),
];
}
const PAGES = {
'/account': {
title: 'Your Atmosphere Account',
sub: 'One account for every atproto app. Everything you make is stored here.',
},
[APPS_PATH]: {
title: 'Your apps',
sub: 'Apps that hold access or have stored data. Open one to see its records and manage access.',
},
[FILES_PATH]: {
title: 'Files',
sub: 'Images and other files your apps have stored in your account.',
},
[SITES_PATH]: {
title: 'Sites',
sub: 'Static sites stored in your repo and served on your own domain.',
},
[GIT_PATH]: {
title: 'Repositories',
sub: 'Git repositories stored in your repo. Push with the atproto remote helper, clone from anywhere.',
},
[DRIVE_PATH]: {
title: 'Drive',
sub: 'Files you keep in your own account, in folders you choose.',
},
[REGISTRY_PATH]: {
title: 'Registry',
sub: 'A container registry in your repo. Push with docker, pull from anywhere.',
},
[PACKAGES_PATH]: {
title: 'Packages',
sub: 'An npm registry in your repo. Publish with npm, install from anywhere.',
},
'/account/history': {
title: 'History',
sub: 'Changes recorded by this server, newest first.',
},
'/account/backups': {
title: 'Backups',
sub: 'Scheduled copies of your account, kept somewhere else.',
},
'/account/security': {
title: 'Sign-in & security',
sub: 'How you sign in, and who has access.',
},
'/account/identity': {
title: 'Username & host',
sub: 'Your name on the network, where your account lives, and how to take it elsewhere.',
},
'/account/settings': {
title: 'Settings',
sub: 'How this page looks and behaves.',
},
};
// Clear the account cookie server-side, then hard-navigate to /account, where a
// signed-out request is served the sign-in screen. Used by the sign-out button
// and when a request reveals the current session has lapsed.
function clearSessionAndReload() {
return fetch('/account/sign-out', { method: 'POST', redirect: 'manual' })
.catch(() => {})
.finally(() => {
window.location.href = '/account';
});
}
export function App() {
const { path, params, navigate, back } = useRouter();
const { data, error } = useQuery({
queryKey: queryKeys.overview,
queryFn: () => get('/overview'),
});
const signOut = clearSessionAndReload;
// A 401 means the cookie no longer maps to a live session — it expired, or in
// local dev the account was reseeded under a new DID. Clear the stale cookie
// and reload onto the sign-in screen, rather than leaving the console to error
// on every request.
const sessionExpired = error?.status === 401;
useEffect(() => {
if (sessionExpired) clearSessionAndReload();
}, [sessionExpired]);
// Keep the palette in step with the stored appearance choice, whether it
// arrived with the overview or was changed from another device.
const theme = data?.theme;
useEffect(() => {
if (theme) applyTheme(theme);
}, [theme]);
if (error) {
if (sessionExpired) return <AppSkeleton />;
return (
<div className="mx-auto max-w-md p-6">
<Notice variant="error">{error.message}</Notice>
</div>
);
}
if (!data) return <AppSkeleton />;
// Destinations as URLs, so navigational UI can be real links (open in a new
// tab, copyable) that still route in place via `go` on a plain click.
const go = (next) => navigate(next);
const appHref = (domain) => `${APPS_PATH}/${encodeURIComponent(domain)}`;
const collectionHref = (collection, space) =>
`${RECORDS_PATH}?collection=${encodeURIComponent(collection)}${
space ? `&space=${encodeURIComponent(space)}` : ''
}`;
const recordHref = (collection, rkey, seq) =>
`${RECORDS_PATH}?collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}${
seq != null ? `&seq=${encodeURIComponent(seq)}` : ''
}`;
// The Files view's filters live in the URL, so a filtered view is a place:
// linkable, sharable, restored by the back button.
const filesHref = (filters) => {
const query = new URLSearchParams();
if (filters.kind) query.set('kind', filters.kind);
if (filters.app) query.set('app', filters.app);
if (filters.unused) query.set('unused', '1');
if (filters.sort === 'largest') query.set('sort', 'largest');
const qs = query.toString();
return qs ? `${FILES_PATH}?${qs}` : FILES_PATH;
};
const sections = sectionsFor(data);
const appDomain = path.startsWith(`${APPS_PATH}/`)
? decodeURIComponent(path.slice(APPS_PATH.length + 1))
: null;
// A repository's own page sits under Repositories in the rail, the same
// way an app's page sits under Your apps.
const gitRepoName = path.startsWith(`${GIT_PATH}/`)
? decodeURIComponent(path.slice(GIT_PATH.length + 1))
: null;
// The apps directory, an app's own page, and a collection's records all sit
// under Your apps in the rail.
const underApps = path === APPS_PATH || appDomain || path === RECORDS_PATH;
const current = underApps
? APPS_PATH
: gitRepoName
? GIT_PATH
: PAGES[path]
? path
: '';
let page;
let body;
if (path === RECORDS_PATH) {
const collection = params.get('collection') || '';
const space = params.get('space') || undefined;
page = {
title: collection || 'Records',
titleBadge: collection ? (
<PublishedCheck nsid={collection} className="size-[18px]" />
) : undefined,
sub: space || undefined,
};
body = (
<RecordsPage
collection={collection}
space={space}
rkey={params.get('rkey') || undefined}
seq={params.get('seq') || undefined}
repoDid={data.did}
recordHref={recordHref}
onNavigate={go}
onBack={() =>
back(`${APPS_PATH}/${encodeURIComponent(nsidDomain(collection))}`)
}
/>
);
} else if (appDomain) {
page = { title: appDomain };
body = (
<AppPage
data={data}
domain={appDomain}
collectionHref={collectionHref}
onNavigate={go}
onBack={() => go(APPS_PATH)}
/>
);
} else if (path === APPS_PATH) {
page = PAGES[APPS_PATH];
body = <AppsPage data={data} appHref={appHref} onNavigate={go} />;
} else if (path === FILES_PATH) {
page = PAGES[path];
body = (
<FilesPage
filters={{
kind: params.get('kind') || undefined,
app: params.get('app') || undefined,
unused: params.get('unused') === '1',
sort: params.get('sort') || undefined,
}}
filesHref={filesHref}
recordHref={recordHref}
onNavigate={go}
/>
);
} else if (path === SITES_PATH) {
page = PAGES[SITES_PATH];
body = <SitesPage />;
} else if (path === GIT_PATH) {
page = PAGES[GIT_PATH];
body = <GitPage data={data} onNavigate={go} />;
} else if (path === REGISTRY_PATH) {
page = PAGES[REGISTRY_PATH];
body = <RegistryPage data={data} />;
} else if (path === PACKAGES_PATH) {
page = PAGES[PACKAGES_PATH];
body = <PackagesPage data={data} />;
} else if (path === DRIVE_PATH) {
page = PAGES[DRIVE_PATH];
body = (
<DrivePage
data={data}
folder={params.get('folder') || ''}
space={params.get('space') || ''}
onNavigate={go}
/>
);
} else if (gitRepoName) {
const repoSpace = params.get('space') || undefined;
page = {
title: gitRepoName,
sub: repoSpace
? 'A private git repository shared through a space.'
: 'A git repository stored in your account.',
back: { href: GIT_PATH, label: 'Repositories' },
};
body = (
<GitRepoPage
name={gitRepoName}
space={repoSpace}
did={data.did}
refName={params.get('ref') || undefined}
path={params.get('path') || ''}
file={params.get('file') || undefined}
view={params.get('view') || undefined}
onNavigate={go}
/>
);
} else if (path === '/account/history') {
page = PAGES[path];
body = <HistoryPage recordHref={recordHref} onNavigate={go} />;
} else if (path === '/account/backups') {
page = PAGES[path];
body = <BackupsPage data={data} />;
} else if (path === '/account/security') {
page = PAGES[path];
body = <SecurityPage data={data} />;
} else if (path === '/account/identity') {
page = PAGES[path];
body = <IdentityPage />;
} else if (path === '/account/settings') {
page = PAGES[path];
body = <SettingsPage data={data} />;
} else {
page = PAGES['/account'];
body = (
<HomePage
data={data}
onNavigate={go}
appHref={appHref}
recordHref={recordHref}
/>
);
}
return (
<Shell
account={data}
sections={sections}
current={current}
page={page}
onNavigate={go}
onSignOut={signOut}
>
{body}
</Shell>
);
}