diff --git a/frontend/src/App.css b/frontend/src/App.css index 4d334df..dbc1c7d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 43ef801..c5bf25c 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -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 | null; + body_sections: BodySectionV2[] | Record | 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 { diff --git a/frontend/src/components/TableOfContents.tsx b/frontend/src/components/TableOfContents.tsx new file mode 100644 index 0000000..7bf9edc --- /dev/null +++ b/frontend/src/components/TableOfContents.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/pages/TechniquePage.tsx b/frontend/src/pages/TechniquePage.tsx index dd2bbd9..38771cd 100644 --- a/frontend/src/pages/TechniquePage.tsx +++ b/frontend/src/pages/TechniquePage.tsx @@ -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) { + // body_sections can be either list (v2) or dict (v1) + let bodySections: BodySectionV2[] | Record | 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; + } + 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) { 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) + 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) && (
- {Object.entries(displaySections).map( - ([sectionTitle, content]: [string, unknown]) => ( -
-

{sectionTitle}

- {typeof content === "string" ? ( -

{content as string}

- ) : typeof content === "object" && content !== null ? ( -
-                      {JSON.stringify(content, null, 2)}
-                    
- ) : ( -

{String(content as string)}

- )} -
- ), + {displayFormat === "v2" && Array.isArray(displaySections) ? ( + <> + + {(displaySections as BodySectionV2[]).map((section) => { + const sectionSlug = slugify(section.heading); + return ( +
+

{section.heading}

+ {section.content && ( +

{parseCitations(section.content, technique.key_moments)}

+ )} + {section.subsections.map((sub) => { + const subSlug = `${sectionSlug}--${slugify(sub.heading)}`; + return ( +
+

{sub.heading}

+ {sub.content && ( +

{parseCitations(sub.content, technique.key_moments)}

+ )} +
+ ); + })} +
+ ); + })} + + ) : ( + /* V1 dict format — original rendering */ + Object.entries(displaySections as Record).map( + ([sectionTitle, content]: [string, unknown]) => ( +
+

{sectionTitle}

+ {typeof content === "string" ? ( +

{content as string}

+ ) : typeof content === "object" && content !== null ? ( +
+                        {JSON.stringify(content, null, 2)}
+                      
+ ) : ( +

{String(content as string)}

+ )} +
+ ), + ) )}
)} diff --git a/frontend/src/utils/citations.tsx b/frontend/src/utils/citations.tsx new file mode 100644 index 0000000..4c3a2a5 --- /dev/null +++ b/frontend/src/utils/citations.tsx @@ -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( + + {idx} + , + ); + } else { + // Invalid index — render as plain text + if (i > 0) links.push(", "); + links.push(String(idx)); + } + } + + nodes.push( + + [{links}] + , + ); + + 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]; +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 5872313..9d3e49a 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file