feat: Added format-aware v2 body_sections rendering with nested TOC, ci…

- "frontend/src/api/public-client.ts"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/components/TableOfContents.tsx"
- "frontend/src/utils/citations.tsx"
- "frontend/src/App.css"

GSD-Task: S05/T01
This commit is contained in:
jlightner 2026-04-03 01:42:56 +00:00
parent dbf3643662
commit 304f3bc069
6 changed files with 340 additions and 21 deletions

View file

@ -1968,6 +1968,129 @@ a.app-footer__repo:hover {
line-height: 1.5; line-height: 1.5;
} }
/* ── Table of Contents ────────────────────────────────────────────────────── */
.technique-toc {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.technique-toc__title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.technique-toc__list {
list-style: none;
padding: 0;
margin: 0;
counter-reset: toc-section;
}
.technique-toc__item {
counter-increment: toc-section;
margin-bottom: 0.25rem;
}
.technique-toc__link {
color: var(--color-accent);
text-decoration: none;
font-size: 0.875rem;
line-height: 1.6;
}
.technique-toc__link::before {
content: counter(toc-section) ". ";
color: var(--color-text-muted);
}
.technique-toc__link:hover {
text-decoration: underline;
}
.technique-toc__sublist {
list-style: none;
padding-left: 1.25rem;
margin: 0.125rem 0 0.25rem;
counter-reset: toc-sub;
}
.technique-toc__subitem {
counter-increment: toc-sub;
}
.technique-toc__sublink {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.8125rem;
line-height: 1.6;
}
.technique-toc__sublink::before {
content: counter(toc-section) "." counter(toc-sub) " ";
color: var(--color-text-muted);
}
.technique-toc__sublink:hover {
color: var(--color-accent);
text-decoration: underline;
}
/* ── V2 subsections ───────────────────────────────────────────────────────── */
.technique-prose__subsection {
margin-left: 0.75rem;
margin-bottom: 1rem;
padding-left: 0.75rem;
border-left: 2px solid var(--color-border);
}
.technique-prose__subsection h3 {
font-size: 1.0625rem;
font-weight: 600;
margin-bottom: 0.375rem;
color: var(--color-text-primary);
}
.technique-prose__subsection p {
font-size: 0.9375rem;
color: var(--color-text-primary);
line-height: 1.7;
}
/* ── Citation links ───────────────────────────────────────────────────────── */
.citation-group {
font-size: 0.75em;
line-height: 1;
vertical-align: super;
}
.citation-link {
color: var(--color-accent);
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.citation-link:hover {
text-decoration: underline;
}
/* ── Scroll margin for section anchors ────────────────────────────────────── */
.technique-prose__section[id],
.technique-prose__subsection[id] {
scroll-margin-top: 5rem;
}
/* ── Key moments list ─────────────────────────────────────────────────────── */ /* ── Key moments list ─────────────────────────────────────────────────────── */
.technique-moments { .technique-moments {

View file

@ -56,6 +56,24 @@ export interface RelatedLinkItem {
reason: string; reason: string;
} }
export interface BodySubSectionV2 {
heading: string;
content: string;
}
export interface BodySectionV2 {
heading: string;
content: string;
subsections: BodySubSectionV2[];
}
export interface SourceVideoSummary {
id: string;
filename: string;
content_type: string;
added_at: string | null;
}
export interface TechniquePageDetail { export interface TechniquePageDetail {
id: string; id: string;
title: string; title: string;
@ -63,7 +81,8 @@ export interface TechniquePageDetail {
topic_category: string; topic_category: string;
topic_tags: string[] | null; topic_tags: string[] | null;
summary: string | null; summary: string | null;
body_sections: Record<string, unknown> | null; body_sections: BodySectionV2[] | Record<string, unknown> | null;
body_sections_format: string;
signal_chains: unknown[] | null; signal_chains: unknown[] | null;
plugins: string[] | null; plugins: string[] | null;
creator_id: string; creator_id: string;
@ -75,6 +94,7 @@ export interface TechniquePageDetail {
creator_info: CreatorInfo | null; creator_info: CreatorInfo | null;
related_links: RelatedLinkItem[]; related_links: RelatedLinkItem[];
version_count: number; version_count: number;
source_videos: SourceVideoSummary[];
} }
export interface TechniquePageVersionSummary { export interface TechniquePageVersionSummary {

View file

@ -0,0 +1,58 @@
/**
* Table of Contents for v2 technique pages with nested sections.
*
* Renders a nested list of anchor links matching the H2/H3 section structure.
* Uses slugified headings as IDs for scroll targeting.
*/
import type { BodySectionV2 } from "../api/public-client";
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
interface TableOfContentsProps {
sections: BodySectionV2[];
}
export default function TableOfContents({ sections }: TableOfContentsProps) {
if (sections.length === 0) return null;
return (
<nav className="technique-toc" aria-label="Table of contents">
<h3 className="technique-toc__title">Contents</h3>
<ol className="technique-toc__list">
{sections.map((section) => {
const sectionSlug = slugify(section.heading);
return (
<li key={sectionSlug} className="technique-toc__item">
<a href={`#${sectionSlug}`} className="technique-toc__link">
{section.heading}
</a>
{section.subsections.length > 0 && (
<ol className="technique-toc__sublist">
{section.subsections.map((sub) => {
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
return (
<li key={subSlug} className="technique-toc__subitem">
<a
href={`#${subSlug}`}
className="technique-toc__sublink"
>
{sub.heading}
</a>
</li>
);
})}
</ol>
)}
</li>
);
})}
</ol>
</nav>
);
}

View file

@ -15,10 +15,13 @@ import {
type TechniquePageDetail as TechniqueDetail, type TechniquePageDetail as TechniqueDetail,
type TechniquePageVersionSummary, type TechniquePageVersionSummary,
type TechniquePageVersionDetail, type TechniquePageVersionDetail,
type BodySectionV2,
} from "../api/public-client"; } from "../api/public-client";
import ReportIssueModal from "../components/ReportIssueModal"; import ReportIssueModal from "../components/ReportIssueModal";
import CopyLinkButton from "../components/CopyLinkButton"; import CopyLinkButton from "../components/CopyLinkButton";
import CreatorAvatar from "../components/CreatorAvatar"; import CreatorAvatar from "../components/CreatorAvatar";
import TableOfContents, { slugify } from "../components/TableOfContents";
import { parseCitations } from "../utils/citations";
import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { useDocumentTitle } from "../hooks/useDocumentTitle";
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
@ -39,6 +42,14 @@ function formatDate(iso: string): string {
/** Extract the displayable fields from a version snapshot, matching TechniqueDetail shape. */ /** Extract the displayable fields from a version snapshot, matching TechniqueDetail shape. */
function snapshotToOverlay(snapshot: Record<string, unknown>) { function snapshotToOverlay(snapshot: Record<string, unknown>) {
// body_sections can be either list (v2) or dict (v1)
let bodySections: BodySectionV2[] | Record<string, unknown> | undefined;
if (Array.isArray(snapshot.body_sections)) {
bodySections = snapshot.body_sections as BodySectionV2[];
} else if (typeof snapshot.body_sections === "object" && snapshot.body_sections !== null) {
bodySections = snapshot.body_sections as Record<string, unknown>;
}
return { return {
title: typeof snapshot.title === "string" ? snapshot.title : undefined, title: typeof snapshot.title === "string" ? snapshot.title : undefined,
summary: typeof snapshot.summary === "string" ? snapshot.summary : undefined, summary: typeof snapshot.summary === "string" ? snapshot.summary : undefined,
@ -49,9 +60,10 @@ function snapshotToOverlay(snapshot: Record<string, unknown>) {
topic_tags: Array.isArray(snapshot.topic_tags) topic_tags: Array.isArray(snapshot.topic_tags)
? (snapshot.topic_tags as string[]) ? (snapshot.topic_tags as string[])
: undefined, : undefined,
body_sections: body_sections: bodySections,
typeof snapshot.body_sections === "object" && snapshot.body_sections !== null body_sections_format:
? (snapshot.body_sections as Record<string, unknown>) typeof snapshot.body_sections_format === "string"
? snapshot.body_sections_format
: undefined, : undefined,
signal_chains: Array.isArray(snapshot.signal_chains) signal_chains: Array.isArray(snapshot.signal_chains)
? (snapshot.signal_chains as unknown[]) ? (snapshot.signal_chains as unknown[])
@ -206,6 +218,7 @@ export default function TechniquePage() {
const displayCategory = overlay?.topic_category ?? technique.topic_category; const displayCategory = overlay?.topic_category ?? technique.topic_category;
const displayTags = overlay?.topic_tags ?? technique.topic_tags; const displayTags = overlay?.topic_tags ?? technique.topic_tags;
const displaySections = overlay?.body_sections ?? technique.body_sections; const displaySections = overlay?.body_sections ?? technique.body_sections;
const displayFormat = overlay?.body_sections_format ?? technique.body_sections_format ?? "v1";
const displayChains = overlay?.signal_chains ?? technique.signal_chains; const displayChains = overlay?.signal_chains ?? technique.signal_chains;
const displayPlugins = overlay?.plugins ?? technique.plugins; const displayPlugins = overlay?.plugins ?? technique.plugins;
const displayQuality = overlay?.source_quality ?? technique.source_quality; const displayQuality = overlay?.source_quality ?? technique.source_quality;
@ -404,9 +417,37 @@ export default function TechniquePage() {
{/* Study guide prose — body_sections */} {/* Study guide prose — body_sections */}
{displaySections && {displaySections &&
Object.keys(displaySections).length > 0 && ( (Array.isArray(displaySections) ? displaySections.length > 0 : Object.keys(displaySections).length > 0) && (
<section className="technique-prose"> <section className="technique-prose">
{Object.entries(displaySections).map( {displayFormat === "v2" && Array.isArray(displaySections) ? (
<>
<TableOfContents sections={displaySections as BodySectionV2[]} />
{(displaySections as BodySectionV2[]).map((section) => {
const sectionSlug = slugify(section.heading);
return (
<div key={sectionSlug} className="technique-prose__section" id={sectionSlug}>
<h2>{section.heading}</h2>
{section.content && (
<p>{parseCitations(section.content, technique.key_moments)}</p>
)}
{section.subsections.map((sub) => {
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
return (
<div key={subSlug} className="technique-prose__subsection" id={subSlug}>
<h3>{sub.heading}</h3>
{sub.content && (
<p>{parseCitations(sub.content, technique.key_moments)}</p>
)}
</div>
);
})}
</div>
);
})}
</>
) : (
/* V1 dict format — original rendering */
Object.entries(displaySections as Record<string, unknown>).map(
([sectionTitle, content]: [string, unknown]) => ( ([sectionTitle, content]: [string, unknown]) => (
<div key={sectionTitle} className="technique-prose__section"> <div key={sectionTitle} className="technique-prose__section">
<h2>{sectionTitle}</h2> <h2>{sectionTitle}</h2>
@ -421,6 +462,7 @@ export default function TechniquePage() {
)} )}
</div> </div>
), ),
)
)} )}
</section> </section>
)} )}

View file

@ -0,0 +1,76 @@
/**
* Parse [N] and [N,M] citation markers in text and replace with React anchor links.
*
* Citations are 1-based indices into the key_moments array.
* Each marker becomes a superscript link to #km-{momentId}.
*/
import React from "react";
import type { KeyMomentSummary } from "../api/public-client";
// Matches [1], [2,3], [1,2,3], etc.
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
/**
* Convert a text string containing [N] or [N,M] markers into an array of
* React nodes plain strings interleaved with citation anchor elements.
*/
export function parseCitations(
text: string,
keyMoments: KeyMomentSummary[],
): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(CITATION_RE)) {
const matchStart = match.index ?? 0;
// Push text before this match
if (matchStart > lastIndex) {
nodes.push(text.slice(lastIndex, matchStart));
}
// Parse the indices from the match group
const rawGroup = match[1];
if (!rawGroup) continue;
const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10));
const links: React.ReactNode[] = [];
for (let i = 0; i < indices.length; i++) {
const idx = indices[i]!;
// Citation indices are 1-based; key_moments array is 0-based
const moment = keyMoments[idx - 1];
if (moment) {
if (i > 0) links.push(", ");
links.push(
<a
key={`${matchStart}-${idx}`}
href={`#km-${moment.id}`}
className="citation-link"
title={moment.title}
>
{idx}
</a>,
);
} else {
// Invalid index — render as plain text
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className="citation-group">
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
// Push remaining text after the last match
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}