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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
150 lines · 5.1 KB Raw History
import { useEffect, useRef, useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { NavigationProvider, useLocation } from '#/lib/navigation.jsx';
import { completeSignIn } from '#/lib/oauth.js';
import { useSession } from '#/lib/session.jsx';
import { BlobsPage } from '#/pages/blobs.jsx';
import { CredentialPage } from '#/pages/credential.jsx';
import { OpsPage } from '#/pages/ops.jsx';
import { RecordPage } from '#/pages/record.jsx';
import { RecordsPage } from '#/pages/records.jsx';
import { SignInPage } from '#/pages/sign-in.jsx';
import { SpacePage } from '#/pages/space.jsx';
import { SpacesPage } from '#/pages/spaces.jsx';

/**
 * Where the authorization server sends the browser back. The code is spent
 * once, so the address bar is cleaned before anything else renders: a reload of
 * this URL would otherwise present a code the server has already refused.
 */
function CallbackPage({ navigate }) {
  const { setSession } = useSession();
  const [error, setError] = useState(null);

  // The exchange consumes the pending sign-in the moment it starts, so it
  // must run once however many times the effect fires. StrictMode fires it
  // twice in development, and a second run would find nothing pending and
  // report a failure over a sign-in that succeeded. The ref survives the
  // simulated remount; a cleanup-cancelled flag would not, and would also
  // cancel the first run's redirect.
  const ran = useRef(false);
  useEffect(() => {
    if (ran.current) return;
    ran.current = true;
    // Read from the address bar rather than from a prop: the code is spent once
    // and this effect must not re-run, so it depends on nothing a render makes
    // anew.
    completeSignIn(new URLSearchParams(window.location.search))
      .then(({ session }) => {
        setSession(session);
        navigate('/');
      })
      .catch((err) => setError(err.message));
  }, [navigate, setSession]);

  if (error) {
    return (
      <div className="mx-auto max-w-md pt-16">
        <Notice variant="error">{error}</Notice>
        <Link href="/" className="mt-4 inline-block text-[13px]">
          Start again
        </Link>
      </div>
    );
  }
  return <Skeleton className="mx-auto mt-16 h-24 max-w-md" />;
}

/**
 * Pick the screen for a path, with the space it acts on read from the query.
 *
 * Each screen is keyed by what it shows. Both editors here hold a draft in
 * state, and moving to another record on the same screen would otherwise open
 * it with the last one's text still in the box.
 */
function Screen({ path, params }) {
  const space = params.get('uri');
  const repo = params.get('repo');
  const collection = params.get('collection');
  const rkey = params.get('rkey');

  if (!space) return <SpacesPage />;
  if (path === '/space') {
    return <SpacePage key={`${space}/${repo}`} space={space} repo={repo} />;
  }
  if (path === '/ops') return <OpsPage space={space} repo={repo} />;
  if (path === '/credential') return <CredentialPage space={space} />;
  if (path === '/blobs') return <BlobsPage space={space} repo={repo} />;
  if (path === '/records') {
    return (
      <RecordsPage
        key={`${space}/${repo}/${collection}`}
        space={space}
        collection={collection}
        repo={repo}
      />
    );
  }
  if (path === '/record') {
    return (
      <RecordPage
        key={`${space}/${repo}/${collection}/${rkey}`}
        space={space}
        collection={collection}
        rkey={rkey}
        repo={repo}
      />
    );
  }
  return <SpacesPage />;
}

export function App() {
  const { path, params, navigate } = useLocation();
  const { session, endSession, writable } = useSession();

  return (
    <NavigationProvider navigate={navigate}>
      <div className="mx-auto max-w-3xl px-5 pb-24">
        {path === '/callback' ? (
          <CallbackPage navigate={navigate} />
        ) : !session ? (
          <SignInPage />
        ) : (
          <>
            <header className="flex flex-wrap items-baseline justify-between gap-3 pt-8 pb-6">
              <Link
                href="/"
                className="font-semibold text-[22px] text-foreground tracking-tight no-underline hover:no-underline"
              >
                Spaces
              </Link>
              <span className="flex items-baseline gap-3">
                <span className="text-muted-foreground text-[13px]">
                  {session.handle}
                </span>
                {!writable && (
                  <span className="text-faint text-xs">read only</span>
                )}
                <Button
                  variant="ghost"
                  size="xs"
                  onClick={() => {
                    endSession();
                    navigate('/');
                  }}
                >
                  Sign out
                </Button>
              </span>
            </header>
            <Screen path={path} params={params} />
          </>
        )}
      </div>
    </NavigationProvider>
  );
}