diff --git a/frontend/package.json b/frontend/package.json index 2396528..6a4eb16 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.18.0", + "version": "0.19.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 8d99fbd..0ddc039 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2193,3 +2193,199 @@ font-size: 11px; text-transform: uppercase; 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 . + + 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; } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 988c085..99e15bc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ 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 { anonymize, EVENTS, identify, track } from './lib/analytics' import Catalog from './components/Catalog.jsx' @@ -12,7 +12,11 @@ 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 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 Admin from './components/Admin.jsx' import AcceptInvitation from './components/AcceptInvitation.jsx' @@ -233,7 +237,13 @@ export default function App() { itself establishes the session on success. */} } /> } /> - } /> + {/* 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. */} + } /> + } /> {/* §14.5 / §14.6: cookie-consent companions to /philosophy. Available to anonymous and authenticated viewers alike. */} } /> @@ -303,9 +313,24 @@ function PhilosophyWithSidebar({ 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 (
- + + }> + } /> + } /> + } /> + } /> + } /> + } /> + +
) } diff --git a/frontend/src/api.js b/frontend/src/api.js index 826c858..0214e8a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -720,6 +720,60 @@ export async function getDocs() { 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 // typeahead). diff --git a/frontend/src/components/Docs.jsx b/frontend/src/components/Docs.jsx deleted file mode 100644 index 7af0c9d..0000000 --- a/frontend/src/components/Docs.jsx +++ /dev/null @@ -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 ( -
-
- - User guide - {!authenticated && ( - Home - )} -
-
- {loading &&

Loading…

} - {error &&

Could not load the guide: {error}

} - {!loading && !error && ( - - )} -
-
- ) -} diff --git a/frontend/src/components/DocsLayout.jsx b/frontend/src/components/DocsLayout.jsx new file mode 100644 index 0000000..c8abc98 --- /dev/null +++ b/frontend/src/components/DocsLayout.jsx @@ -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 ( +
+
+ + + Docs + {!authenticated && ( + + Home + + )} +
+
+ +
+ +
+
+ {drawerOpen && ( +
+ ) +} + +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 ( + + ) +} diff --git a/frontend/src/components/DocsSessionIndex.jsx b/frontend/src/components/DocsSessionIndex.jsx new file mode 100644 index 0000000..601933e --- /dev/null +++ b/frontend/src/components/DocsSessionIndex.jsx @@ -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 ( +
+

{header}

+ {status === 'loading' &&

Loading…

} + {status === 'notfound' && ( +
+

+ No transcripts have been published for this session yet.{' '} + + About sessions + +

+
+ )} + {status === 'error' && ( +
+

Couldn't reach the session-history repo.

+ +
+ )} + {status === 'ok' && files.length === 0 && ( +
+

This session has no transcripts published.

+
+ )} + {status === 'ok' && files.length > 0 && ( +
    + {files.map(f => ( +
  • + + {f} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/DocsSessionTranscript.jsx b/frontend/src/components/DocsSessionTranscript.jsx new file mode 100644 index 0000000..4f38d6b --- /dev/null +++ b/frontend/src/components/DocsSessionTranscript.jsx @@ -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 ( +
+
+ + ← Session {nnnn} + +
+ {status === 'loading' &&

Loading…

} + {status === 'notfound' && ( +
+

This transcript isn't published yet.

+

+ + ← Back to session {nnnn} + +

+
+ )} + {status === 'error' && ( +
+

Couldn't reach the session-history repo.

+ +
+ )} + {status === 'ok' && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/components/DocsSessionsAbout.jsx b/frontend/src/components/DocsSessionsAbout.jsx new file mode 100644 index 0000000..f6ad0bf --- /dev/null +++ b/frontend/src/components/DocsSessionsAbout.jsx @@ -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 ( +
+

About sessions

+ {status === 'loading' &&

Loading…

} + {status === 'notfound' && ( +
+

+ 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. +

+

+ In the meantime, browse the source repo directly at{' '} + + wiggleverse/ohm-session-history + . +

+
+ )} + {status === 'error' && ( +
+

Couldn't reach the session-history repo.

+ +
+ )} + {status === 'ok' && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/components/DocsUserGuide.jsx b/frontend/src/components/DocsUserGuide.jsx new file mode 100644 index 0000000..d2b9588 --- /dev/null +++ b/frontend/src/components/DocsUserGuide.jsx @@ -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 ( +
+

User guide

+ {loading &&

Loading…

} + {error &&

Could not load the guide: {error}

} + {!loading && !error && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js index 6e0633e..689282d 100644 --- a/frontend/src/lib/analytics.js +++ b/frontend/src/lib/analytics.js @@ -97,6 +97,13 @@ export const EVENTS = Object.freeze({ // v0.17.0 / item #16 — admin-create user + invite email. USER_INVITED: 'User Invited', INVITE_CLAIMED: 'Invite Claimed', + // v0.19.0 / item #30 — `/docs/*` flyout + sessions browser. Carries + // `section`: 'user-guide' | 'sessions/about' | 'sessions/' | + // 'sessions//' 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/` or by + // exact transcript. + DOC_VIEWED: 'Doc Viewed', }) // Internal state.