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:
parent
dbf3643662
commit
304f3bc069
6 changed files with 340 additions and 21 deletions
|
|
@ -1968,6 +1968,129 @@ a.app-footer__repo:hover {
|
|||
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 ─────────────────────────────────────────────────────── */
|
||||
|
||||
.technique-moments {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,24 @@ export interface RelatedLinkItem {
|
|||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -63,7 +81,8 @@ export interface TechniquePageDetail {
|
|||
topic_category: string;
|
||||
topic_tags: 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;
|
||||
plugins: string[] | null;
|
||||
creator_id: string;
|
||||
|
|
@ -75,6 +94,7 @@ export interface TechniquePageDetail {
|
|||
creator_info: CreatorInfo | null;
|
||||
related_links: RelatedLinkItem[];
|
||||
version_count: number;
|
||||
source_videos: SourceVideoSummary[];
|
||||
}
|
||||
|
||||
export interface TechniquePageVersionSummary {
|
||||
|
|
|
|||
58
frontend/src/components/TableOfContents.tsx
Normal file
58
frontend/src/components/TableOfContents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,10 +15,13 @@ import {
|
|||
type TechniquePageDetail as TechniqueDetail,
|
||||
type TechniquePageVersionSummary,
|
||||
type TechniquePageVersionDetail,
|
||||
type BodySectionV2,
|
||||
} from "../api/public-client";
|
||||
import ReportIssueModal from "../components/ReportIssueModal";
|
||||
import CopyLinkButton from "../components/CopyLinkButton";
|
||||
import CreatorAvatar from "../components/CreatorAvatar";
|
||||
import TableOfContents, { slugify } from "../components/TableOfContents";
|
||||
import { parseCitations } from "../utils/citations";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||
|
||||
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. */
|
||||
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 {
|
||||
title: typeof snapshot.title === "string" ? snapshot.title : 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)
|
||||
? (snapshot.topic_tags as string[])
|
||||
: undefined,
|
||||
body_sections:
|
||||
typeof snapshot.body_sections === "object" && snapshot.body_sections !== null
|
||||
? (snapshot.body_sections as Record<string, unknown>)
|
||||
body_sections: bodySections,
|
||||
body_sections_format:
|
||||
typeof snapshot.body_sections_format === "string"
|
||||
? snapshot.body_sections_format
|
||||
: undefined,
|
||||
signal_chains: Array.isArray(snapshot.signal_chains)
|
||||
? (snapshot.signal_chains as unknown[])
|
||||
|
|
@ -206,6 +218,7 @@ export default function TechniquePage() {
|
|||
const displayCategory = overlay?.topic_category ?? technique.topic_category;
|
||||
const displayTags = overlay?.topic_tags ?? technique.topic_tags;
|
||||
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 displayPlugins = overlay?.plugins ?? technique.plugins;
|
||||
const displayQuality = overlay?.source_quality ?? technique.source_quality;
|
||||
|
|
@ -404,23 +417,52 @@ export default function TechniquePage() {
|
|||
|
||||
{/* Study guide prose — body_sections */}
|
||||
{displaySections &&
|
||||
Object.keys(displaySections).length > 0 && (
|
||||
(Array.isArray(displaySections) ? displaySections.length > 0 : Object.keys(displaySections).length > 0) && (
|
||||
<section className="technique-prose">
|
||||
{Object.entries(displaySections).map(
|
||||
([sectionTitle, content]: [string, unknown]) => (
|
||||
<div key={sectionTitle} className="technique-prose__section">
|
||||
<h2>{sectionTitle}</h2>
|
||||
{typeof content === "string" ? (
|
||||
<p>{content as string}</p>
|
||||
) : typeof content === "object" && content !== null ? (
|
||||
<pre className="technique-prose__json">
|
||||
{JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p>{String(content as string)}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
{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]) => (
|
||||
<div key={sectionTitle} className="technique-prose__section">
|
||||
<h2>{sectionTitle}</h2>
|
||||
{typeof content === "string" ? (
|
||||
<p>{content as string}</p>
|
||||
) : typeof content === "object" && content !== null ? (
|
||||
<pre className="technique-prose__json">
|
||||
{JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p>{String(content as string)}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
|
|
|||
76
frontend/src/utils/citations.tsx
Normal file
76
frontend/src/utils/citations.tsx
Normal 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];
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue