atboards (React SPA)#
A static SPA reimplementation of the atboards web UI. No server, no database — all reads go directly to Slingshot/Constellation, all writes go directly to the user's PDS via atproto OAuth (DPoP). Designed to be hosted as static files on Cloudflare Pages or any static host.
Stack#
- Vite + React 19 + TypeScript
- react-router-dom v7 (history routing)
@atproto/oauth-client-browserfor OAuth (same library red-dwarf uses)@atproto/apiAgentfor authenticated XRPC writes- Tailwind CSS v4 (via
@tailwindcss/vite)
All reads (boards, threads, replies, news, bans, hides, identity resolution) go through public Microcosm services:
slingshot.microcosm.blue— getRecord, listRecords, resolveMiniDocconstellation.microcosm.blue— getBacklinks (used to find threads in a board, replies to a thread, news for a site, quotes of a reply)ufos-api.microcosm.blue— random BBS discovery on the home page
All writes go to agent.com.atproto.repo.{createRecord, putRecord, deleteRecord, uploadBlob} against the user's PDS, using the OAuth/DPoP session held by @atproto/oauth-client-browser.
Layout#
react/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── public/
│ ├── client-metadata.json # OAuth client metadata for production (edit before deploy)
│ ├── _redirects # Cloudflare Pages SPA fallback
│ ├── favicon.svg
│ └── hero.svg
└── src/
├── main.tsx # Root, BrowserRouter + AuthProvider
├── App.tsx # Routes
├── index.css # Tailwind entry
├── components/
│ ├── Layout.tsx # Header / footer / breadcrumb
│ └── Localtime.tsx
├── lib/
│ ├── lexicon.ts # xyz.atboards.* collection IDs
│ ├── util.ts # date / AT-URI helpers
│ ├── atproto.ts # Slingshot + Constellation read wrappers
│ ├── bbs.ts # `resolveBBS()` — port of core/resolver.py
│ ├── oauth.ts # BrowserOAuthClient setup
│ ├── auth.tsx # AuthProvider / useAuth() hook
│ └── writes.ts # PDS write helpers (createThread, createReply, …)
└── pages/
├── Home.tsx
├── Login.tsx
├── Callback.tsx # /oauth/callback (no logic — provider handles it)
├── Site.tsx # /bbs/:handle
├── Board.tsx # /bbs/:handle/board/:slug
├── Thread.tsx # /bbs/:handle/thread/:did/:tid
├── Account.tsx # /account (inbox + BBS controls)
├── SysopCreate.tsx # /account/create
├── SysopEdit.tsx # /account/edit
├── SysopModerate.tsx # /account/moderate
└── NotFound.tsx
Routes#
Mirror the Python app exactly:
| Route | Page |
|---|---|
/ |
Home |
/login |
Login |
/oauth/callback |
Callback |
/account |
Account |
/account/create |
SysopCreate |
/account/edit |
SysopEdit |
/account/moderate |
SysopModerate |
/bbs/:handle |
Site |
/bbs/:handle/board/:slug |
Board |
/bbs/:handle/thread/:did/:tid |
Thread |
The old /api/threads/... and /api/replies/... JSON endpoints are gone — pages do the same aggregation client-side via lib/atproto.ts.
Development#
cd react
npm install
npm run dev
For OAuth in dev, BrowserOAuthClient automatically falls back to a loopback client when no clientMetadata is provided. This works for http://localhost:5173 without any tunneling — the client_id becomes http://localhost/?... and atproto auth servers accept it.
Production deployment (Cloudflare Pages)#
- Edit
public/client-metadata.jsonand replace everyREPLACE_WITH_YOUR_DOMAINwith your deployed origin (e.g.https://atbbs.app). - Set the build env var
VITE_PUBLIC_URL=https://atbbs.appsolib/oauth.tsuses the production metadata path. npm run build— outputs static files todist/.- Deploy
dist/to Pages. The includedpublic/_redirectsmakes Pages serveindex.htmlfor all routes (history routing). - Verify
https://your.domain/client-metadata.jsonis publicly fetchable — that URL is yourclient_id, atproto auth servers will fetch it during the OAuth handshake.
Auth flow#
- User hits
/login, types handle, presses log in. useAuth().login(handle)→BrowserOAuthClient.signIn(handle)→ DPoP keypair generated, PAR pushed, browser redirected to the user's authserver.- Authserver redirects back to
/oauth/callback?code=…&state=…. - The
AuthProviderrunsclient.init()on every mount; on the callback page that detects the code, exchanges it, and returns aOAuthSession. - We wrap that session in an
Agentand stash{did, handle, pdsUrl}in context. - Session/refresh tokens are persisted by the OAuth client in IndexedDB; reloads silently restore the session.