~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
382 lines · 12.1 KB Raw History
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>
  );
}