adb5d25715
Roadmap item #32 (session/transcript page polish) plus the /docs surfaces' share of #31, for rfc-app v0.21.0. - DocsSessionIndex: the session root (/docs/sessions/:nnnn) no longer renders a dead-end "N transcript(s) — select from the nav" placeholder. It now renders a transcript INLINE: the lone transcript for single-transcript sessions, or the `.0` driver transcript (falling back to first-by-sort) for multi-transcript sessions, with the remaining siblings listed/linked above the body. URL stays stable to the session number — inline render, no 301. - DocsSessionTranscript: adds a compact metadata header above the body (title; started/ended parsed from the filename's ISO segments, human-readable; derived duration; optional TL;DR from the manifest's `tldr` string field, graceful-degrade when absent; external "View source on git.wiggleverse.org" link). The parse/header helpers are exported so the inline-collapse view reuses identical rendering. - Docs.css (new): token-based styling for the new metadata-header + sibling-list elements only; existing docs classes stay owned by App.css to avoid racing the #31 sweep. - DocsUserGuide: loading/error states brought onto the shared .docs-empty/.docs-error convention with a retry button. No backend change: /api/docs/sessions/manifest already passes the full sessions.json entry through, so a `tldr` field on an entry reaches the frontend with no docs_sessions.py change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
8.2 KiB
React
247 lines
8.2 KiB
React
// DocsSessionIndex.jsx — v0.21.0 (was v0.20.0 / roadmap item #30).
|
|
//
|
|
// Per-session landing at `/docs/sessions/:nnnn`. v0.20.0 rendered a
|
|
// dead-end "N transcript(s) in this session. Select one from the
|
|
// navigation." placeholder. v0.21.0 / roadmap item #32 collapses that:
|
|
// the session root now renders a transcript INLINE so the URL is never
|
|
// an empty stop.
|
|
//
|
|
// - Exactly one transcript → render it inline at the session root.
|
|
// - Multiple transcripts → render the `.0` driver transcript
|
|
// inline (fall back to the first file by
|
|
// sort order if there's no `.0`), AND
|
|
// list/link the remaining transcripts so
|
|
// the siblings are one click away.
|
|
//
|
|
// The URL stays stable to the session number — this is an inline
|
|
// render, not a 301/redirect. The per-transcript route
|
|
// (`/docs/sessions/:nnnn/:filename`) still exists and is what the
|
|
// sibling links and the left-nav transcript rows point at.
|
|
//
|
|
// The transcript count + filenames come from `/api/docs/sessions/:nnnn/index`
|
|
// so the empty-state ("no transcripts yet"), not-found, and error
|
|
// paths remain meaningful when the upstream is mid-publish or
|
|
// unreachable. The metadata header + body rendering are imported from
|
|
// DocsSessionTranscript.jsx so the inline view is byte-identical to the
|
|
// standalone per-transcript view.
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
|
import {
|
|
getSessionsManifest,
|
|
getSessionIndex,
|
|
getSessionTranscript,
|
|
} from '../api.js'
|
|
import {
|
|
TranscriptMetaHeader,
|
|
transcriptOrdinal,
|
|
} from './DocsSessionTranscript.jsx'
|
|
import { EVENTS, track } from '../lib/analytics'
|
|
import './Docs.css'
|
|
|
|
// Pick the transcript to render inline at the session root: prefer the
|
|
// `.0` driver transcript; otherwise the first file by sort order. The
|
|
// backend already returns the file list sorted, so `files[0]` is a
|
|
// stable fallback.
|
|
function pickPrimary(files) {
|
|
if (!files || files.length === 0) return null
|
|
const driver = files.find(f => /^SESSION-\d{4}\.0-TRANSCRIPT/.test(f))
|
|
return driver || files[0]
|
|
}
|
|
|
|
export default function DocsSessionIndex() {
|
|
const { nnnn } = useParams()
|
|
const [title, setTitle] = useState('')
|
|
const [tldr, setTldr] = useState('')
|
|
const [files, setFiles] = useState([])
|
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
|
const [reloadTick, setReloadTick] = useState(0)
|
|
|
|
// The inline body for the primary transcript.
|
|
const [body, setBody] = useState('')
|
|
const [bodyStatus, setBodyStatus] = useState('idle') // idle | loading | ok | notfound | error
|
|
|
|
useEffect(() => {
|
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
|
}, [nnnn])
|
|
|
|
// Title + optional TL;DR from the manifest — cheap, cached server-side.
|
|
useEffect(() => {
|
|
let active = true
|
|
getSessionsManifest()
|
|
.then(payload => {
|
|
if (!active) return
|
|
const entry = (payload && payload[nnnn]) || {}
|
|
setTitle(entry.title || '')
|
|
// `tldr` is an optional manifest field (string). Absent ⇒ the
|
|
// header renders no TL;DR line (graceful degrade).
|
|
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
|
|
})
|
|
.catch(() => {
|
|
// Title + TL;DR are decorative; the transcript list + body
|
|
// fetches below are the load-bearing ones.
|
|
})
|
|
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 primary = status === 'ok' ? pickPrimary(files) : null
|
|
|
|
// Fetch the primary transcript body once we know which file it is.
|
|
useEffect(() => {
|
|
if (!primary) {
|
|
setBody('')
|
|
setBodyStatus('idle')
|
|
return
|
|
}
|
|
let active = true
|
|
setBodyStatus('loading')
|
|
getSessionTranscript(nnnn, primary)
|
|
.then(text => {
|
|
if (!active) return
|
|
setBody(text || '')
|
|
setBodyStatus('ok')
|
|
})
|
|
.catch(e => {
|
|
if (!active) return
|
|
setBodyStatus(e.status === 404 ? 'notfound' : 'error')
|
|
})
|
|
return () => { active = false }
|
|
}, [nnnn, primary, reloadTick])
|
|
|
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
|
|
|
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
|
const siblings = primary ? files.filter(f => f !== primary) : []
|
|
|
|
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' && primary && (
|
|
<>
|
|
{siblings.length > 0 && (
|
|
<nav className="docs-session-siblings" aria-label="Transcripts in this session">
|
|
<span className="docs-session-siblings-label">Transcripts</span>
|
|
<ul className="docs-session-siblings-list">
|
|
<li>
|
|
<span
|
|
className="docs-session-siblings-current"
|
|
aria-current="true"
|
|
>
|
|
{transcriptOrdinal(primary)} (shown below)
|
|
</span>
|
|
</li>
|
|
{siblings.map(f => (
|
|
<li key={f}>
|
|
<Link
|
|
to={`/docs/sessions/${nnnn}/${f}`}
|
|
aria-label={`Transcript ${transcriptOrdinal(f)}`}
|
|
data-amp-track-name="Docs Session Sibling Transcript"
|
|
data-amp-track-session={nnnn}
|
|
data-amp-track-filename={f}
|
|
>
|
|
{transcriptOrdinal(f)}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
)}
|
|
|
|
{bodyStatus === 'loading' && <p className="muted">Loading transcript…</p>}
|
|
{bodyStatus === 'notfound' && (
|
|
<div className="docs-empty">
|
|
<p>This transcript isn't published yet.</p>
|
|
</div>
|
|
)}
|
|
{bodyStatus === '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 Inline Retry"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
)}
|
|
{bodyStatus === 'ok' && (
|
|
<>
|
|
<TranscriptMetaHeader
|
|
nnnn={nnnn}
|
|
filename={primary}
|
|
title={title}
|
|
tldr={tldr}
|
|
/>
|
|
<div className="philosophy-body">
|
|
<MarkdownPreview content={body} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|