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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2193,3 +2193,199 @@
|
|||||||
font-size: 11px; text-transform: uppercase;
|
font-size: 11px; text-transform: uppercase;
|
||||||
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
|
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────
|
||||||
|
v0.19.0 / roadmap item #30 — /docs/* flyout + sessions browser.
|
||||||
|
|
||||||
|
The shell is `.docs-layout` (header + body). The body is a flex
|
||||||
|
row: `.docs-nav` is the persistent left sidebar on desktop and a
|
||||||
|
slide-out drawer on mobile (toggled by the `.docs-drawer-toggle`
|
||||||
|
icon in `.docs-header`). The content area `.docs-content` mounts
|
||||||
|
the sub-route via React Router's <Outlet/>.
|
||||||
|
|
||||||
|
The article body inside each sub-route reuses `.philosophy-body`
|
||||||
|
(defined above) for the markdown rendering — same `marked` lib,
|
||||||
|
same typography. The new classes here just handle the chrome
|
||||||
|
(sidebar + drawer + header).
|
||||||
|
────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.docs-layout {
|
||||||
|
height: 100%;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 24px 12px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0ee;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.docs-back, .docs-drawer-toggle {
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
color: #4b5563; font-size: 13px;
|
||||||
|
padding: 4px 8px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.docs-back:hover, .docs-drawer-toggle:hover {
|
||||||
|
background: #f3f4f6; color: #111;
|
||||||
|
}
|
||||||
|
.docs-drawer-toggle {
|
||||||
|
display: none;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.docs-title {
|
||||||
|
font-size: 13px; color: #6b7280;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.docs-signin {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px; color: #4b5563; text-decoration: none;
|
||||||
|
}
|
||||||
|
.docs-signin:hover { color: #111; text-decoration: underline; }
|
||||||
|
|
||||||
|
.docs-body {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 260px;
|
||||||
|
border-right: 1px solid #f0f0ee;
|
||||||
|
padding: 24px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fafaf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav-inner { display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
.docs-nav-section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.docs-nav-section-label {
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.docs-nav-list {
|
||||||
|
list-style: none; padding: 0; margin: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.docs-nav-list a {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 10px; border-radius: 4px;
|
||||||
|
color: #1f2937; text-decoration: none;
|
||||||
|
font-size: 14px; line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.docs-nav-list a:hover { background: #f0f0ee; }
|
||||||
|
.docs-nav-list a.active {
|
||||||
|
background: #e7e5e4; color: #111; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav-skeleton .skeleton-row {
|
||||||
|
display: block;
|
||||||
|
height: 14px; margin: 8px 10px;
|
||||||
|
background: linear-gradient(90deg, #f0f0ee 25%, #e7e5e4 50%, #f0f0ee 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: docs-skeleton-shimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes docs-skeleton-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-nav-error {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px; color: #9a3412;
|
||||||
|
background: #fff7ed; border: 1px solid #fed7aa; border-radius: 4px;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.docs-nav-error button {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #fff; border: 1px solid #fed7aa;
|
||||||
|
color: #9a3412; font-size: 12px;
|
||||||
|
padding: 4px 8px; border-radius: 4px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.docs-nav-error button:hover { background: #fff7ed; }
|
||||||
|
|
||||||
|
.docs-content {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 32px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-article {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.docs-article-title {
|
||||||
|
font-size: 28px; font-weight: 700; margin: 0 0 24px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
.docs-breadcrumbs { margin: 0 0 16px; font-size: 13px; }
|
||||||
|
.docs-breadcrumbs a {
|
||||||
|
color: #4b5563; text-decoration: none;
|
||||||
|
}
|
||||||
|
.docs-breadcrumbs a:hover { color: #111; text-decoration: underline; }
|
||||||
|
|
||||||
|
.docs-session-files {
|
||||||
|
list-style: none; padding: 0; margin: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.docs-session-files a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 14px; border-radius: 6px;
|
||||||
|
background: #fafaf9; border: 1px solid #f0f0ee;
|
||||||
|
color: #1f2937; text-decoration: none;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px; word-break: break-all;
|
||||||
|
}
|
||||||
|
.docs-session-files a:hover {
|
||||||
|
background: #f3f4f6; border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-empty, .docs-error {
|
||||||
|
padding: 16px; border-radius: 6px;
|
||||||
|
background: #fafaf9; border: 1px solid #f0f0ee;
|
||||||
|
font-size: 14px; line-height: 1.6; color: #4b5563;
|
||||||
|
}
|
||||||
|
.docs-error { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
|
||||||
|
.docs-error button {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: #fff; border: 1px solid #fecaca;
|
||||||
|
color: #991b1b; font-size: 13px;
|
||||||
|
padding: 6px 12px; border-radius: 4px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.docs-error button:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
.docs-drawer-scrim {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: none; padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: the sidebar becomes a slide-out drawer. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.docs-drawer-toggle { display: inline-block; }
|
||||||
|
.docs-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; bottom: 0; left: 0;
|
||||||
|
width: 80%; max-width: 320px;
|
||||||
|
z-index: 10;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 200ms ease-out;
|
||||||
|
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.docs-body--drawer-open .docs-nav {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.docs-body--drawer-open .docs-drawer-scrim {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.docs-content { padding: 16px 18px 64px; }
|
||||||
|
}
|
||||||
|
|||||||
+29
-4
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { getMe, subscribeToNotifications } from './api'
|
import { getMe, subscribeToNotifications } from './api'
|
||||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||||
import Catalog from './components/Catalog.jsx'
|
import Catalog from './components/Catalog.jsx'
|
||||||
@@ -12,7 +12,11 @@ import Landing from './components/Landing.jsx'
|
|||||||
import Login from './components/Login.jsx'
|
import Login from './components/Login.jsx'
|
||||||
import BetaPending from './components/BetaPending.jsx'
|
import BetaPending from './components/BetaPending.jsx'
|
||||||
import Philosophy from './components/Philosophy.jsx'
|
import Philosophy from './components/Philosophy.jsx'
|
||||||
import Docs from './components/Docs.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 NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||||||
@@ -233,7 +237,13 @@ export default function App() {
|
|||||||
itself establishes the session on success. */}
|
itself establishes the session on success. */}
|
||||||
<Route path="/invites/claim" element={<InviteClaim />} />
|
<Route path="/invites/claim" element={<InviteClaim />} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||||
<Route path="/docs" element={<DocsWithSidebar 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.
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||||
Available to anonymous and authenticated viewers alike. */}
|
Available to anonymous and authenticated viewers alike. */}
|
||||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||||
@@ -303,9 +313,24 @@ function PhilosophyWithSidebar({ viewer }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DocsWithSidebar({ viewer }) {
|
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 (
|
return (
|
||||||
<main className="chrome-pane">
|
<main className="chrome-pane">
|
||||||
<Docs authenticated={!!viewer} />
|
<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 />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -720,6 +720,60 @@ export async function getDocs() {
|
|||||||
return jsonOrThrow(await fetch('/api/docs'))
|
return jsonOrThrow(await fetch('/api/docs'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The framework mediates reads against the public
|
||||||
|
// `wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||||
|
// `/docs/sessions/*` surface inherits the same chrome as
|
||||||
|
// `/docs/user-guide`. Three text-bearing endpoints return markdown
|
||||||
|
// (Content-Type: text/markdown) and the manifest returns JSON. We
|
||||||
|
// wrap each into a small helper.
|
||||||
|
//
|
||||||
|
// 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex`
|
||||||
|
// throws an Error with `.status === 404` so the UI can render its own
|
||||||
|
// empty-state. 502 (gitea unreachable) throws `.status === 502` so
|
||||||
|
// the UI can offer a retry button.
|
||||||
|
|
||||||
|
export async function getSessionsManifest() {
|
||||||
|
// Manifest 404 is mapped server-side to HTTP 200 + `{}` so this
|
||||||
|
// helper never throws on the empty-state path.
|
||||||
|
return jsonOrThrow(await fetch('/api/docs/sessions/manifest'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _textOrThrow(res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = ''
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
detail = body.detail || JSON.stringify(body)
|
||||||
|
} catch {
|
||||||
|
detail = await res.text()
|
||||||
|
}
|
||||||
|
const error = new Error(detail || `HTTP ${res.status}`)
|
||||||
|
error.status = res.status
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionsAbout() {
|
||||||
|
return _textOrThrow(await fetch('/api/docs/sessions/about'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionTranscript(nnnn, filename) {
|
||||||
|
return _textOrThrow(await fetch(
|
||||||
|
`/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionIndex(nnnn) {
|
||||||
|
return jsonOrThrow(await fetch(
|
||||||
|
`/api/docs/sessions/${encodeURIComponent(nnnn)}/index`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||||
// typeahead).
|
// typeahead).
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
// `/docs` — the user-facing guide.
|
|
||||||
//
|
|
||||||
// Sibling of Philosophy.jsx: same chrome, same data path, different
|
|
||||||
// source file. Renders DOCS.md verbatim with light chrome around it.
|
|
||||||
// Reachable anonymously, same as `/philosophy`, so a visitor can read
|
|
||||||
// the guide before deciding to sign in.
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
|
||||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
|
||||||
import { getDocs } from '../api.js'
|
|
||||||
|
|
||||||
export default function Docs({ authenticated }) {
|
|
||||||
const [body, setBody] = useState('')
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
getDocs()
|
|
||||||
.then(r => { if (active) setBody(r.body || '') })
|
|
||||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
|
||||||
.finally(() => { if (active) setLoading(false) })
|
|
||||||
return () => { active = false }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="philosophy-page">
|
|
||||||
<header className="philosophy-header">
|
|
||||||
<button
|
|
||||||
className="philosophy-back"
|
|
||||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
<span className="philosophy-title">User guide</span>
|
|
||||||
{!authenticated && (
|
|
||||||
<Link className="philosophy-signin" to="/">Home</Link>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
<article className="philosophy-body">
|
|
||||||
{loading && <p className="muted">Loading…</p>}
|
|
||||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
|
||||||
{!loading && !error && (
|
|
||||||
<MarkdownPreview content={body} />
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// DocsSessionIndex.jsx — v0.19.0 / roadmap item #30.
|
||||||
|
//
|
||||||
|
// Per-session index page at `/docs/sessions/:nnnn`. Lists every
|
||||||
|
// transcript published under the session's NNNN/ folder, linked to
|
||||||
|
// the per-transcript view.
|
||||||
|
//
|
||||||
|
// The transcript list comes from `/api/docs/sessions/:nnnn/index`,
|
||||||
|
// which the framework derives via the gitea contents API (see
|
||||||
|
// backend/app/docs_sessions.fetch_session_index). We also read the
|
||||||
|
// session's `title` from the manifest fetch so the page header
|
||||||
|
// matches the flyout nav entry.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import { getSessionsManifest, getSessionIndex } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSessionIndex() {
|
||||||
|
const { nnnn } = useParams()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
||||||
|
}, [nnnn])
|
||||||
|
|
||||||
|
// Title from manifest — cheap, manifest is cached server-side.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getSessionsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
const entry = payload && payload[nnnn]
|
||||||
|
setTitle((entry && entry.title) || '')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Title is decorative; failure to load just leaves the header
|
||||||
|
// showing the bare NNNN. The transcript list fetch below is
|
||||||
|
// the load-bearing one.
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn])
|
||||||
|
|
||||||
|
// File list from the per-session index endpoint.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSessionIndex(nnnn)
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
setFiles((payload && payload.files) || [])
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn, reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">{header}</h1>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>
|
||||||
|
No transcripts have been published for this session yet.{' '}
|
||||||
|
<Link
|
||||||
|
to="/docs/sessions/about"
|
||||||
|
aria-label="About sessions"
|
||||||
|
data-amp-track-name="Docs Session Empty About Link"
|
||||||
|
>
|
||||||
|
About sessions
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Session Index Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && files.length === 0 && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>This session has no transcripts published.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && files.length > 0 && (
|
||||||
|
<ul className="docs-session-files">
|
||||||
|
{files.map(f => (
|
||||||
|
<li key={f}>
|
||||||
|
<Link
|
||||||
|
to={`/docs/sessions/${nnnn}/${f}`}
|
||||||
|
aria-label={`Open transcript ${f}`}
|
||||||
|
data-amp-track-name="Docs Session Transcript Open"
|
||||||
|
data-amp-track-session={nnnn}
|
||||||
|
data-amp-track-filename={f}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
|
||||||
|
//
|
||||||
|
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
|
||||||
|
// transcript body via the backend mediator and renders it through the
|
||||||
|
// shared MarkdownPreview.
|
||||||
|
//
|
||||||
|
// Empty-state contract:
|
||||||
|
// 404 → "This transcript isn't published yet" with a link back to
|
||||||
|
// the parent session index
|
||||||
|
// 502 → "Couldn't reach the session-history repo" + retry button
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getSessionTranscript } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSessionTranscript() {
|
||||||
|
const { nnnn, filename } = useParams()
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
||||||
|
}, [nnnn, filename])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSessionTranscript(nnnn, filename)
|
||||||
|
.then(text => {
|
||||||
|
if (!active) return
|
||||||
|
setBody(text || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn, filename, reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<div className="docs-breadcrumbs">
|
||||||
|
<Link
|
||||||
|
to={`/docs/sessions/${nnnn}`}
|
||||||
|
aria-label={`Back to session ${nnnn} index`}
|
||||||
|
data-amp-track-name="Docs Transcript Back To Index"
|
||||||
|
>
|
||||||
|
← Session {nnnn}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>This transcript isn't published yet.</p>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
to={`/docs/sessions/${nnnn}`}
|
||||||
|
aria-label={`Back to session ${nnnn}`}
|
||||||
|
data-amp-track-name="Docs Transcript Back To Session"
|
||||||
|
>
|
||||||
|
← Back to session {nnnn}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Transcript Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// DocsSessionsAbout.jsx — v0.19.0 / roadmap item #30.
|
||||||
|
//
|
||||||
|
// Renders the README.md of the public `wiggleverse/ohm-session-history`
|
||||||
|
// repo at `/docs/sessions/about`. The framework backend mediates the
|
||||||
|
// gitea fetch (see backend/app/docs_sessions.py); this component
|
||||||
|
// handles three response paths:
|
||||||
|
//
|
||||||
|
// 200 → render the markdown via MarkdownPreview
|
||||||
|
// 404 → "About not yet published" empty-state (the upstream README
|
||||||
|
// doesn't exist yet — happens when a deployment hasn't
|
||||||
|
// restructured its session-history repo yet, expected at
|
||||||
|
// v0.19.0 deploy time per the CHANGELOG)
|
||||||
|
// 502 → "Couldn't reach the session-history repo" with a retry
|
||||||
|
// button. The retry just re-invokes the fetch — no extra
|
||||||
|
// backoff because the backend cache already smoothes
|
||||||
|
// repeated 502s.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getSessionsAbout } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSessionsAbout() {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: 'sessions/about' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSessionsAbout()
|
||||||
|
.then(text => {
|
||||||
|
if (!active) return
|
||||||
|
setBody(text || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">About sessions</h1>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>
|
||||||
|
The session-history About page isn't published yet. Sessions
|
||||||
|
are still authored — once a few have shipped, this page will
|
||||||
|
render the canonical introduction.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In the meantime, browse the source repo directly at{' '}
|
||||||
|
<a
|
||||||
|
href="https://git.wiggleverse.org/wiggleverse/ohm-session-history"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Open session-history repo on gitea"
|
||||||
|
data-amp-track-name="Docs Sessions About Repo Link"
|
||||||
|
>
|
||||||
|
wiggleverse/ohm-session-history
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Sessions About Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// DocsUserGuide.jsx — v0.19.0 / roadmap item #30.
|
||||||
|
//
|
||||||
|
// Renders DOCS.md at `/docs/user-guide`. Was `/docs` before v0.19.0
|
||||||
|
// (the v0.14.0 single-route Docs.jsx surface, now superseded). The
|
||||||
|
// content path is unchanged: backend reads `DOCS.md` from disk and
|
||||||
|
// serves it at `/api/docs`. The body is rendered via the existing
|
||||||
|
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
||||||
|
// so we don't introduce a second markdown library).
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getDocs } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsUserGuide() {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getDocs()
|
||||||
|
.then(r => { if (active) setBody(r.body || '') })
|
||||||
|
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||||
|
.finally(() => { if (active) setLoading(false) })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">User guide</h1>
|
||||||
|
{loading && <p className="muted">Loading…</p>}
|
||||||
|
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -97,6 +97,13 @@ export const EVENTS = Object.freeze({
|
|||||||
// v0.17.0 / item #16 — admin-create user + invite email.
|
// v0.17.0 / item #16 — admin-create user + invite email.
|
||||||
USER_INVITED: 'User Invited',
|
USER_INVITED: 'User Invited',
|
||||||
INVITE_CLAIMED: 'Invite Claimed',
|
INVITE_CLAIMED: 'Invite Claimed',
|
||||||
|
// v0.19.0 / item #30 — `/docs/*` flyout + sessions browser. Carries
|
||||||
|
// `section`: 'user-guide' | 'sessions/about' | 'sessions/<NNNN>' |
|
||||||
|
// 'sessions/<NNNN>/<filename>' so the dashboard can answer which
|
||||||
|
// docs surfaces get read most. The transcript-section value includes
|
||||||
|
// the filename so an aggregator can group by `sessions/<NNNN>` or by
|
||||||
|
// exact transcript.
|
||||||
|
DOC_VIEWED: 'Doc Viewed',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Internal state.
|
// Internal state.
|
||||||
|
|||||||
Reference in New Issue
Block a user