948ee88160
The top-level catch-all silently redirected unknown paths to "/", and the nested /admin/*, /p/:projectId/*, and /docs/* route groups had no catch-all (invalid subpaths rendered a blank pane). Add a shared NotFound component and wire it into all four route groups so a bad/typo'd URL shows a clear 404 with a link home. Client-side 404 UI (SPA still serves HTTP 200). Patch 0.52.2 → 0.52.3; CHANGELOG updated. Caught by the operator on PPE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
594 lines
27 KiB
React
594 lines
27 KiB
React
import { useEffect, useRef, useState } from 'react'
|
||
import { Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||
import { getMe, subscribeToNotifications } from './api'
|
||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||
import { useLastState } from './lib/useLastState'
|
||
import { brandTitle } from './lib/brand'
|
||
import { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths'
|
||
import { useDeployment } from './context/DeploymentProvider'
|
||
import ProjectLayout from './components/ProjectLayout.jsx'
|
||
import NotFound from './components/NotFound.jsx'
|
||
import Directory from './components/Directory.jsx'
|
||
import ProjectSwitcher from './components/ProjectSwitcher.jsx'
|
||
import Catalog from './components/Catalog.jsx'
|
||
import Inbox from './components/Inbox.jsx'
|
||
import RFCView from './components/RFCView.jsx'
|
||
import PRView from './components/PRView.jsx'
|
||
import ProposalView from './components/ProposalView.jsx'
|
||
import ProposeModal from './components/ProposeModal.jsx'
|
||
import CollectionDirectory from './components/CollectionDirectory.jsx'
|
||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||
import Landing from './components/Landing.jsx'
|
||
import Login from './components/Login.jsx'
|
||
import BetaPending from './components/BetaPending.jsx'
|
||
import Philosophy from './components/Philosophy.jsx'
|
||
import DocsLayout from './components/DocsLayout.jsx'
|
||
import DocsUserGuide from './components/DocsUserGuide.jsx'
|
||
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
|
||
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
|
||
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
|
||
import DocsSpec from './components/DocsSpec.jsx'
|
||
import DocsSpecsIndex from './components/DocsSpecsIndex.jsx'
|
||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||
import Admin from './components/Admin.jsx'
|
||
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||
import InviteClaim from './components/InviteClaim.jsx'
|
||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
||
import Privacy from './pages/Privacy.jsx'
|
||
import Cookies from './pages/Cookies.jsx'
|
||
import './App.css'
|
||
|
||
export default function App() {
|
||
const [me, setMe] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [proposeOpen, setProposeOpen] = useState(false)
|
||
const [catalogVersion, setCatalogVersion] = useState(0)
|
||
const [inboxOpen, setInboxOpen] = useState(false)
|
||
const [unreadCount, setUnreadCount] = useState(0)
|
||
const [inboxTick, setInboxTick] = useState(0)
|
||
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
|
||
// re-open even if the user has already made a choice. The settings
|
||
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
||
// event that bumps this.
|
||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||
// v0.23.0 / item #29 — flips true once the #21-Part-C identify effect
|
||
// has fired (or once we've confirmed there's no authenticated user to
|
||
// identify). useLastState gates its resume redirect on this so the
|
||
// redirect always happens AFTER identify, preserving identify-then-
|
||
// track ordering.
|
||
const [identifyReady, setIdentifyReady] = useState(false)
|
||
const navigate = useNavigate()
|
||
const location = useLocation()
|
||
// §22.9 — runtime deployment config (name for the brand, default project id
|
||
// for building corpus links this slice; see DeploymentProvider).
|
||
const deployment = useDeployment()
|
||
// §22.4 — the project the viewer is currently in (from the /p/<id>/ URL),
|
||
// so the propose modal (App-level chrome, above the route tree) targets the
|
||
// right project. Falls back to the deployment default off a project route.
|
||
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
|
||
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
|
||
// §22 S2 — the collection the viewer is currently in (from the /c/<cid>/ URL
|
||
// segment), so a propose targets that collection. Falls back to the default.
|
||
const _colMatch = location.pathname.match(/^\/p\/[^/]+\/c\/([^/]+)/)
|
||
const currentCollectionId = (_colMatch && _colMatch[1]) || DEFAULT_COLLECTION
|
||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||
// query params so they need no prop-threading from deep in a comment
|
||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||
// `?contribute=<slug>&term=<term>` opens the contribute-request form.
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const proposeParam = searchParams.get('propose')
|
||
const contributeSlug = searchParams.get('contribute')
|
||
const contributeTerm = searchParams.get('term')
|
||
const clearParams = (...keys) => {
|
||
const next = new URLSearchParams(searchParams)
|
||
keys.forEach(k => next.delete(k))
|
||
setSearchParams(next, { replace: true })
|
||
}
|
||
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
||
// route change; the analytics wrapper itself decides whether
|
||
// anything ships out (consent + key check). The first fire is
|
||
// also covered because `location` is set on mount.
|
||
const lastPathRef = useRef(null)
|
||
useEffect(() => {
|
||
const path = location.pathname + (location.search || '')
|
||
if (lastPathRef.current === path) return
|
||
lastPathRef.current = path
|
||
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
|
||
}, [location.pathname, location.search])
|
||
|
||
// §22.9 — tab title from runtime config. On deployment-chrome routes the
|
||
// title is the deployment name; under a `/p/<project>/` route ProjectLayout
|
||
// owns it (the project name), so skip those here.
|
||
useEffect(() => {
|
||
if (deployment.loading) return
|
||
if (!location.pathname.startsWith('/p/')) {
|
||
document.title = brandTitle(deployment.name)
|
||
}
|
||
}, [location.pathname, deployment.loading, deployment.name])
|
||
|
||
// v0.15.0 + #21 Part C — bind the authenticated user id AND
|
||
// durable user properties to the analytics session when sign-in
|
||
// lands; reset on sign-out (viewer flips to null). The wrapper
|
||
// queues these calls until consent + init resolve, so the order
|
||
// is safe even on a cold load.
|
||
//
|
||
// Property bag passed to identify (set vs setOnce per #21 Part C):
|
||
// set: role, permission_state, passcode_set, device_trusted
|
||
// (these can change mid-account-life — refresh each sign-in)
|
||
// setOnce: first_sign_in_at, account_created_at
|
||
// (immutable user-history markers — set on the first
|
||
// sign-in that observes them, never overwritten)
|
||
//
|
||
// PII discipline: NO email, NO display_name, NO gitea_login passed
|
||
// through — Amplitude only sees opaque ids + enums + timestamps +
|
||
// booleans.
|
||
const lastUserIdRef = useRef(null)
|
||
useEffect(() => {
|
||
const uid = me?.authenticated ? me.user?.id : null
|
||
const viewer = me?.authenticated ? me.user : null
|
||
if (uid != null && lastUserIdRef.current !== uid) {
|
||
lastUserIdRef.current = uid
|
||
const props = {}
|
||
if (viewer?.role != null) props.role = viewer.role
|
||
if (viewer?.permission_state != null) props.permission_state = viewer.permission_state
|
||
if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set
|
||
if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted
|
||
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
||
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||
identify({ user_id: String(uid), properties: props })
|
||
// v0.23.0 / item #29 — identify has now fired for this sign-in;
|
||
// release useLastState's resume redirect (it waits on this).
|
||
setIdentifyReady(true)
|
||
} else if (uid == null && lastUserIdRef.current != null) {
|
||
// Sign-out edge — App-level reset is handled separately by the
|
||
// sign-out gesture that fires User Signed Out. Clear our local
|
||
// memo so a fresh sign-in re-fires identify.
|
||
lastUserIdRef.current = null
|
||
}
|
||
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
||
|
||
// v0.23.0 / item #29 — once `me` has resolved, if there's no
|
||
// authenticated user there is nothing to identify, so release the
|
||
// resume gate immediately (anonymous boots have no resume to do, but
|
||
// the hook still needs the gate resolved to be a clean no-op).
|
||
useEffect(() => {
|
||
if (me != null && !me.authenticated) setIdentifyReady(true)
|
||
}, [me])
|
||
|
||
useEffect(() => {
|
||
const handler = () => setConsentReopenTick(t => t + 1)
|
||
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
||
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
getMe()
|
||
.then(setMe)
|
||
.catch(() => setMe({ authenticated: false }))
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
// v0.23.0 / item #29 — server-side sign-in state resume. The hook
|
||
// debounce-posts the current route for authenticated users and, once
|
||
// identify has fired, redirects a fresh sign-in (which hard-lands on
|
||
// "/") to the user's stored last route. Anonymous users: no-op.
|
||
useLastState({
|
||
authenticated: !!me?.authenticated,
|
||
pathname: location.pathname,
|
||
identifyReady,
|
||
lastRoute: me?.authenticated ? me.user?.last_route : null,
|
||
navigate,
|
||
})
|
||
|
||
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||
// so the badge counter and the toast surface stay in lockstep with
|
||
// the inbox. Tabs that miss an event because they were closed pick
|
||
// it up on next sign-in via the snapshot frame.
|
||
useEffect(() => {
|
||
if (!me?.authenticated) return undefined
|
||
const close = subscribeToNotifications({
|
||
onSnapshot: payload => setUnreadCount(payload.unread_count || 0),
|
||
onNotification: payload => {
|
||
setUnreadCount(c => c + 1)
|
||
setInboxTick(t => t + 1)
|
||
// §15.3: personal-direct events get a toast even when the user
|
||
// isn't on the relevant view — they're the named subject.
|
||
// Churn never toasts; structural toasts only when it lands on
|
||
// a slug the user is currently viewing (URL match).
|
||
const isPersonal = payload.category === 'personal-direct'
|
||
// §22.10: entry URLs are now /p/<project>/e/<slug>; match + link on the
|
||
// generic /e/<slug> segment (default project is the served corpus).
|
||
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/e/${payload.rfc_slug}`)
|
||
if (isPersonal || onCurrentSlug) {
|
||
showToast({
|
||
summary: payload.summary,
|
||
category: payload.category,
|
||
link: payload.rfc_slug && deployment.defaultProjectId
|
||
? entryPath(deployment.defaultProjectId, payload.rfc_slug)
|
||
: null,
|
||
})
|
||
}
|
||
},
|
||
onRead: () => {
|
||
setUnreadCount(c => Math.max(0, c - 1))
|
||
setInboxTick(t => t + 1)
|
||
},
|
||
})
|
||
return close
|
||
}, [me?.authenticated, deployment.defaultProjectId])
|
||
|
||
if (loading) {
|
||
return <div className="boot">Loading…</div>
|
||
}
|
||
|
||
// The deployment is in private beta: anonymous visitors get the full
|
||
// app in read-only mode (viewer = null is passed through to every
|
||
// component), and write affordances are hidden at the component
|
||
// level. v0.8.0 (§6.1 / item #6): authenticated users with
|
||
// `permission_state='pending'` also pass through as `viewer` with
|
||
// their state attached — every write-gated affordance reads the
|
||
// state and treats pending the same as anonymous, while reads
|
||
// remain open. The /beta-pending page is the home root for a
|
||
// pending user.
|
||
const viewer = me?.authenticated ? me.user : null
|
||
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
||
const isPending = viewer && viewer.permission_state === 'pending'
|
||
|
||
return (
|
||
<div className="app">
|
||
<header className="app-header">
|
||
<div className="app-brand">
|
||
{/* §22.9: deployment name from runtime config (replaces the
|
||
build-time VITE_APP_NAME); neutral 'RFC' during the pre-fetch
|
||
paint via brandTitle(). */}
|
||
<Link to="/">{brandTitle(deployment.name)}</Link>
|
||
{/* §22.10: project switcher — deployment chrome; renders only when
|
||
2+ projects are visible to the caller. */}
|
||
<ProjectSwitcher />
|
||
</div>
|
||
<div className="header-right">
|
||
{/* §14.3: the persistent About link. One word, no badge, no
|
||
state — visible from every screen so a viewer mid-PR who
|
||
wonders why a conversation is public can reach the answer
|
||
in two clicks. Anonymous viewers see it too. */}
|
||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||
About
|
||
</Link>
|
||
<Link to="/docs" className="header-about" title="User guide">
|
||
Docs
|
||
</Link>
|
||
{viewer && (
|
||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||
Settings
|
||
</Link>
|
||
)}
|
||
{isAdmin && (
|
||
<Link to="/admin" className="header-admin" title="Admin home base">
|
||
Admin
|
||
</Link>
|
||
)}
|
||
{viewer && (
|
||
<button
|
||
className="inbox-trigger"
|
||
onClick={() => setInboxOpen(o => !o)}
|
||
aria-label="Inbox"
|
||
title="Inbox (§15.2)"
|
||
>
|
||
<svg
|
||
width="18" height="18" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||
>
|
||
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||
<path d="m3.5 6.5 8.5 6 8.5-6" />
|
||
</svg>
|
||
{unreadCount > 0 && (
|
||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
{viewer ? (
|
||
<>
|
||
<span className="user-name">{viewer.display_name}</span>
|
||
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
|
||
<a
|
||
className="btn-link"
|
||
href="/auth/logout"
|
||
onClick={() => {
|
||
// v0.15.0 — fire the sign-out event before the
|
||
// hard nav. The wrapper's track() is sync-enqueue;
|
||
// the underlying SDK flush is best-effort across
|
||
// navigation. anonymize() clears the user binding
|
||
// so any post-nav anonymous events on the next
|
||
// page aren't attributed to the prior user.
|
||
track(EVENTS.USER_SIGNED_OUT)
|
||
anonymize()
|
||
}}
|
||
>Sign out</a>
|
||
</>
|
||
) : (
|
||
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
|
||
Sign in <span className="beta-chip">Beta</span>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</header>
|
||
{isPending && <PendingAccessBanner />}
|
||
<div className="app-body">
|
||
<Routes>
|
||
<Route path="/welcome" element={<Landing />} />
|
||
<Route path="/login" element={<Login />} />
|
||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
|
||
Anonymous viewers see a sign-in prompt; signed-in users
|
||
see the preview + accept gesture. */}
|
||
<Route path="/invitations/accept" element={
|
||
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
|
||
} />
|
||
{/* v0.17.0 — roadmap item #16. The claim landing page for
|
||
admin-issued invites. Anonymous-reachable; the call
|
||
itself establishes the session on success. */}
|
||
<Route path="/invites/claim" element={<InviteClaim />} />
|
||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||
{/* v0.19.0 / roadmap item #30 — /docs/* is a hub with sub-nav.
|
||
The bare /docs path redirects to the user guide; sessions
|
||
browser lives at /docs/sessions/*. See DocsLayout.jsx
|
||
for the flyout shape and CHANGELOG v0.19.0 for the
|
||
upgrade path. */}
|
||
<Route path="/docs" element={<Navigate to="/docs/user-guide" replace />} />
|
||
<Route path="/docs/*" element={<DocsWithSidebar viewer={viewer} />} />
|
||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||
Available to anonymous and authenticated viewers alike. */}
|
||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
|
||
{viewer && (
|
||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
||
)}
|
||
{isAdmin && (
|
||
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
|
||
)}
|
||
{/* §22.10: the deployment landing at `/` — redirect into the single
|
||
visible project (N=1, OHM's "land in the corpus" UX) or show the
|
||
directory when 2+ are visible. */}
|
||
<Route path="/" element={<DeploymentLanding />} />
|
||
{/* §22.10: per-project subtree. ProjectLayout fetches the project,
|
||
applies its theme, provides ProjectContext, and guards the corpus
|
||
(served only for the corpus-served default; others get a
|
||
placeholder). The generic `/e/` segment carries every entry type;
|
||
the noun ("RFC"/"Spec"/"Feature") is a type-driven label. */}
|
||
<Route path="/p/:projectId/*" element={
|
||
<ProjectLayout>
|
||
<Catalog
|
||
viewer={viewer}
|
||
onProposeRFC={() => setProposeOpen(true)}
|
||
version={catalogVersion}
|
||
/>
|
||
<main className="main-pane">
|
||
<Routes>
|
||
{/* §22 S2: the project landing is the collection directory —
|
||
it lists collections, or (C3.7/C3.8) redirects into the
|
||
sole visible collection when there is exactly one. */}
|
||
<Route path="" element={<CollectionDirectoryRoute />} />
|
||
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
||
/c/<collection>/ segment redirect into the default
|
||
collection, so old bookmarks keep working. */}
|
||
<Route path="e/:slug" element={<LegacyCorpusRedirect kind="entry" />} />
|
||
<Route path="e/:slug/pr/:prNumber" element={<LegacyCorpusRedirect kind="entryPr" />} />
|
||
<Route path="proposals/:prNumber" element={<LegacyCorpusRedirect kind="proposal" />} />
|
||
{/* Collection-scoped corpus. Serving stays project-scoped in
|
||
S1 (collection = default); collection-aware serving is S2. */}
|
||
<Route path="c/:collectionId" element={<Welcome viewer={viewer} />} />
|
||
<Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} />
|
||
<Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||
<Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||
<Route path="*" element={<NotFound />} />
|
||
</Routes>
|
||
</main>
|
||
</ProjectLayout>
|
||
} />
|
||
{/* Any unmatched path is a real 404 — surface it rather than
|
||
silently bouncing home, so a bad/typo'd URL is visible. */}
|
||
<Route path="*" element={<NotFound />} />
|
||
</Routes>
|
||
</div>
|
||
{(proposeOpen || proposeParam != null) && viewer && (
|
||
<ProposeModal
|
||
viewer={viewer}
|
||
initialTitle={proposeParam || ''}
|
||
projectId={currentProjectId}
|
||
collectionId={currentCollectionId}
|
||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||
onSubmitted={({ pr_number }) => {
|
||
setProposeOpen(false)
|
||
clearParams('propose')
|
||
setCatalogVersion(v => v + 1)
|
||
navigate(proposalPath(currentProjectId, pr_number, currentCollectionId))
|
||
}}
|
||
/>
|
||
)}
|
||
{contributeSlug && viewer && (
|
||
<ContributeRequestForm
|
||
slug={contributeSlug}
|
||
term={contributeTerm || ''}
|
||
onClose={() => clearParams('contribute', 'term')}
|
||
/>
|
||
)}
|
||
{inboxOpen && viewer && (
|
||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||
)}
|
||
<ToastHost />
|
||
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// §22 S2 — the project landing at /p/:projectId/ is the collection directory.
|
||
// A tiny wrapper reads the route's projectId and hands it to CollectionDirectory
|
||
// (which lists collections, or redirects into the sole one — C3.7/C3.8).
|
||
function CollectionDirectoryRoute() {
|
||
const { projectId } = useParams()
|
||
return <CollectionDirectory projectId={projectId} />
|
||
}
|
||
|
||
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
||
// /c/<collection>/ segment: redirect into the default collection, preserving any
|
||
// query string (e.g. ?branch=).
|
||
function LegacyCorpusRedirect({ kind }) {
|
||
const { projectId, slug, prNumber } = useParams()
|
||
const { search } = useLocation()
|
||
const base = `/p/${projectId}/c/${DEFAULT_COLLECTION}`
|
||
let to = `${base}/`
|
||
if (kind === 'entry') to = `${base}/e/${slug}`
|
||
else if (kind === 'entryPr') to = `${base}/e/${slug}/pr/${prNumber}`
|
||
else if (kind === 'proposal') to = `${base}/proposals/${prNumber}`
|
||
return <Navigate to={to + (search || '')} replace />
|
||
}
|
||
|
||
function DeploymentLanding() {
|
||
// §22.10 + design decision 2 — N=1 lands in the single visible project so
|
||
// OHM's "land in the corpus" UX is preserved; the directory appears only
|
||
// when 2+ projects are visible to the caller. Visibility is per-caller
|
||
// (§22.5), so the unlisted projects never count toward the directory.
|
||
const { projects, defaultProjectId, defaultProjectReadable, loading } = useDeployment()
|
||
if (loading) {
|
||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||
}
|
||
if (projects.length === 1) {
|
||
return <Navigate to={`/p/${projects[0].id}/`} replace />
|
||
}
|
||
if (projects.length === 0 && defaultProjectReadable && defaultProjectId) {
|
||
// No enumerable projects but the default is readable by this viewer (e.g. an
|
||
// anon hitting a deployment whose only project is unlisted-but-default) —
|
||
// land there. §22 S5: when the default is gated to this viewer (C3.2) or
|
||
// absent (C3.1, no projects), we do NOT bounce into a 404 — we fall through
|
||
// to the directory's role-aware empty state below.
|
||
return <Navigate to={`/p/${defaultProjectId}/`} replace />
|
||
}
|
||
return <Directory />
|
||
}
|
||
|
||
function PolicyShell({ children }) {
|
||
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
|
||
// render full-width without the catalog rail. The components inside
|
||
// carry their own back affordance per Philosophy.jsx's pattern.
|
||
return <main className="chrome-pane">{children}</main>
|
||
}
|
||
|
||
function PhilosophyWithSidebar({ viewer }) {
|
||
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||
// all use the full app body — no catalog left pane, no propose modal.
|
||
// The header carries the navigation back; the body is a single
|
||
// reading surface.
|
||
return (
|
||
<main className="chrome-pane">
|
||
<Philosophy authenticated={!!viewer} />
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function DocsWithSidebar({ viewer }) {
|
||
// v0.19.0 / roadmap item #30 — the `/docs/*` surface is a flyout
|
||
// shell with sub-routes. The shell (sidebar + content area) is the
|
||
// DocsLayout outlet host; the sub-routes mount their respective
|
||
// pages into the outlet. Bare `/docs/sessions` redirects to the
|
||
// sessions about page so deep-linkers and the flyout's "Sessions"
|
||
// header both land somewhere coherent.
|
||
return (
|
||
<main className="chrome-pane">
|
||
<Routes>
|
||
<Route element={<DocsLayout authenticated={!!viewer} />}>
|
||
<Route index element={<Navigate to="user-guide" replace />} />
|
||
<Route path="user-guide" element={<DocsUserGuide />} />
|
||
<Route path="sessions" element={<Navigate to="about" replace />} />
|
||
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
||
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
||
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
||
{/* v0.20.0 — /docs/specs/* surface (framework spec + flotilla spec
|
||
at runtime via gitea raw). Bare /docs/specs lands on the
|
||
client-side redirect to the first configured spec. */}
|
||
<Route path="specs" element={<DocsSpecsIndex />} />
|
||
<Route path="specs/:name" element={<DocsSpec />} />
|
||
<Route path="*" element={<NotFound />} />
|
||
</Route>
|
||
</Routes>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function NotificationSettingsWithSidebar({ viewer }) {
|
||
return (
|
||
<main className="chrome-pane">
|
||
<NotificationSettings viewer={viewer} />
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function AdminWithSidebar({ viewer }) {
|
||
return (
|
||
<main className="chrome-pane">
|
||
<Admin viewer={viewer} />
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function PendingAccessBanner() {
|
||
// v0.8.0 — thin banner shown on every page (other than /beta-pending
|
||
// itself, which carries the same message in larger form) when the
|
||
// signed-in user's `permission_state='pending'`. Sign-out works
|
||
// normally via the header affordance.
|
||
return (
|
||
<div className="pending-access-banner">
|
||
Your beta access request is in review.{' '}
|
||
<Link to="/beta-pending">Learn more →</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Welcome({ viewer }) {
|
||
// v0.8.0 — a pending user landing on "/" gets the same page they'd
|
||
// see at /beta-pending, inline. This is the post-OTC home root for
|
||
// a user awaiting admin grant.
|
||
if (viewer && viewer.permission_state === 'pending') {
|
||
return <BetaPending viewer={viewer} />
|
||
}
|
||
if (!viewer) {
|
||
return (
|
||
<div className="welcome">
|
||
<h1>Welcome.</h1>
|
||
<p>
|
||
The catalog on the left lists every super-draft and active RFC in the
|
||
framework. Open one to read the canonical body and the public
|
||
conversation behind each definition.
|
||
</p>
|
||
<p>
|
||
Discussion and contribution are in private <strong>Beta</strong> —
|
||
read freely, and <Link to="/login">sign in</Link> if your email has
|
||
been invited.
|
||
</p>
|
||
<p>
|
||
Wondering why a conversation is public, why graduation costs what it
|
||
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
||
philosophy</Link>.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="welcome">
|
||
<h1>Welcome, {viewer.display_name}.</h1>
|
||
<p>
|
||
The catalog on the left lists every super-draft and active RFC in the
|
||
framework. Open one to read the canonical body, or use{' '}
|
||
<strong>Propose New RFC</strong> at the bottom of the catalog to open an
|
||
idea PR against the meta repository.
|
||
</p>
|
||
<p>
|
||
Wondering why a conversation is public, why graduation costs what it
|
||
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
||
philosophy</Link>.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|