822f4266f6
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>
212 lines
7.0 KiB
React
212 lines
7.0 KiB
React
// 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>
|
|
)
|
|
}
|