v0.19.0 frontend: /docs/* route tree + flyout nav + sessions browser
Reorganizes the /docs surface from a single DOCS.md route into a hub with a left-side flyout nav and three new sub-routes for the on-site sessions browser (roadmap item #30): /docs → redirect to /docs/user-guide /docs/user-guide → existing DOCS.md content /docs/sessions → redirect to /docs/sessions/about /docs/sessions/about → README.md of ohm-session-history /docs/sessions/<NNNN> → per-session transcript index /docs/sessions/<NNNN>/<f> → per-transcript view The flyout is a persistent left sidebar on desktop and a slide-out drawer on mobile (toggled by a ☰ button in the docs header). Its session list is driven by the /api/docs/sessions/manifest fetch — loading shows a skeleton; 502 shows an inline retry; empty manifest shows only the "About" row. Each sub-route owns its own empty-state / error handling: - 404 transcripts render "This transcript isn't published yet" with a link back to the parent session index, no JS crash. - 502 (gitea unreachable) renders a retry button. - Manifest 404 is mapped to {} server-side so the flyout renders cleanly with no error banner when no sessions are published yet. Analytics (per SPEC §21): - new EVENTS.DOC_VIEWED ("Doc Viewed") fires on each sub-route mount with `section`: 'user-guide' | 'sessions/about' | 'sessions/<NNNN>' | 'sessions/<NNNN>/<filename>'. - every interactive nav element carries aria-label + data-amp-track-name so autocapture rows are readable. The existing v0.14.0 Docs.jsx component is dropped — its content moved verbatim into DocsUserGuide.jsx; the new layout subsumes the back-button + signed-out home affordances it used to carry. All four new sub-routes reuse the existing MarkdownPreview renderer (marked + mermaid lazy-load) so no second markdown library lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// DocsLayout.jsx — v0.19.0 / roadmap item #30.
|
||||
//
|
||||
// Left-side flyout nav + content area for the `/docs/*` route tree:
|
||||
//
|
||||
// /docs → redirect to /docs/user-guide
|
||||
// /docs/user-guide → DOCS.md (existing v0.14.0 content)
|
||||
// /docs/sessions → redirect to /docs/sessions/about
|
||||
// /docs/sessions/about → README.md from the sessions repo
|
||||
// /docs/sessions/:nnnn → per-session index page
|
||||
// /docs/sessions/:nnnn/:file → per-transcript view
|
||||
//
|
||||
// The flyout is a persistent left sidebar on desktop and a slide-out
|
||||
// drawer on mobile (toggled by the icon button in the docs header).
|
||||
// The session list is driven by the `/api/docs/sessions/manifest`
|
||||
// fetch:
|
||||
// - loading → skeleton in the nav (three placeholder rows)
|
||||
// - manifest 502 → error banner in the nav with "Try again"
|
||||
// - empty manifest → only "About" under Sessions; no NNNN rows
|
||||
//
|
||||
// Amplitude analytics (per SPEC §21):
|
||||
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
|
||||
// the sub-route component owns the fire (it knows the section).
|
||||
// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
|
||||
// so autocapture rows are readable rather than ":nth-child(7)".
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||
import { getSessionsManifest } from '../api.js'
|
||||
|
||||
export default function DocsLayout({ authenticated }) {
|
||||
const [manifest, setManifest] = useState(null)
|
||||
const [manifestState, setManifestState] = useState('loading') // loading | ok | error
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setManifestState('loading')
|
||||
getSessionsManifest()
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
setManifest(payload || {})
|
||||
setManifestState('ok')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setManifest({})
|
||||
setManifestState('error')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [reloadTick])
|
||||
|
||||
// Close the mobile drawer on every navigation so a click in the nav
|
||||
// doesn't strand the user on a drawer-open view.
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const retryManifest = useCallback(() => {
|
||||
setReloadTick(t => t + 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="docs-layout">
|
||||
<header className="docs-header">
|
||||
<button
|
||||
className="docs-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
aria-label="Back to previous page"
|
||||
data-amp-track-name="Docs Back"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
className="docs-drawer-toggle"
|
||||
onClick={() => setDrawerOpen(o => !o)}
|
||||
aria-label="Toggle docs navigation"
|
||||
aria-expanded={drawerOpen}
|
||||
data-amp-track-name="Docs Drawer Toggle"
|
||||
>
|
||||
<span aria-hidden>☰</span>
|
||||
</button>
|
||||
<span className="docs-title">Docs</span>
|
||||
{!authenticated && (
|
||||
<Link
|
||||
className="docs-signin"
|
||||
to="/"
|
||||
aria-label="Home"
|
||||
data-amp-track-name="Docs Home"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
<div className={'docs-body' + (drawerOpen ? ' docs-body--drawer-open' : '')}>
|
||||
<aside className="docs-nav" aria-label="Docs navigation">
|
||||
<DocsNav
|
||||
manifest={manifest}
|
||||
manifestState={manifestState}
|
||||
onRetry={retryManifest}
|
||||
currentPath={location.pathname}
|
||||
/>
|
||||
</aside>
|
||||
<main className="docs-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{drawerOpen && (
|
||||
<button
|
||||
className="docs-drawer-scrim"
|
||||
aria-label="Close drawer"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
data-amp-track-name="Docs Drawer Close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
||||
const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
|
||||
|
||||
// Sort session keys ascending (newest sessions render last). The
|
||||
// manifest's keys are zero-padded 4-digit strings so lexicographic
|
||||
// order is the same as numeric.
|
||||
const sessionKeys = Object.keys(manifest || {}).sort()
|
||||
|
||||
return (
|
||||
<nav className="docs-nav-inner">
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Docs</div>
|
||||
<ul className="docs-nav-list">
|
||||
<li>
|
||||
<Link
|
||||
to="/docs/user-guide"
|
||||
className={isActive('/docs/user-guide') ? 'active' : ''}
|
||||
aria-label="User Guide"
|
||||
data-amp-track-name="Docs Nav User Guide"
|
||||
>
|
||||
User Guide
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Sessions</div>
|
||||
<ul className="docs-nav-list">
|
||||
<li>
|
||||
<Link
|
||||
to="/docs/sessions/about"
|
||||
className={currentPath === '/docs/sessions/about' ? 'active' : ''}
|
||||
aria-label="About sessions"
|
||||
data-amp-track-name="Docs Nav Sessions About"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{manifestState === 'loading' && (
|
||||
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{manifestState === 'error' && (
|
||||
<div className="docs-nav-error" role="alert">
|
||||
<span>Couldn't load session list.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
aria-label="Retry session list"
|
||||
data-amp-track-name="Docs Nav Sessions Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
||||
<ul className="docs-nav-list">
|
||||
{sessionKeys.map(nnnn => {
|
||||
const entry = manifest[nnnn] || {}
|
||||
const title = entry.title || ''
|
||||
const label = title ? `${nnnn} — ${title}` : nnnn
|
||||
const to = `/docs/sessions/${nnnn}`
|
||||
return (
|
||||
<li key={nnnn}>
|
||||
<Link
|
||||
to={to}
|
||||
className={isActive(to) ? 'active' : ''}
|
||||
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
|
||||
data-amp-track-name="Docs Nav Session"
|
||||
data-amp-track-session={nnnn}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user