From 15708b5ab23146f65823a61bc933288d3b948ea9 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Fri, 22 Aug 2025 09:03:21 +0200 Subject: [PATCH] Table of Content functionality --- frontend/src/app/globals.css | 77 ++++++++- frontend/src/app/stories/[id]/detail/page.tsx | 10 ++ frontend/src/app/stories/[id]/page.tsx | 76 ++++++++- .../src/components/stories/RichTextEditor.tsx | 4 +- .../components/stories/TableOfContents.tsx | 158 ++++++++++++++++++ 5 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/stories/TableOfContents.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1e81bab..c863ef3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -85,13 +85,28 @@ line-height: 1.7; } - .reading-content h1, - .reading-content h2, - .reading-content h3, - .reading-content h4, - .reading-content h5, + .reading-content h1 { + @apply text-2xl font-bold mt-8 mb-4 theme-header; + } + + .reading-content h2 { + @apply text-xl font-bold mt-6 mb-3 theme-header; + } + + .reading-content h3 { + @apply text-lg font-semibold mt-6 mb-3 theme-header; + } + + .reading-content h4 { + @apply text-base font-semibold mt-4 mb-2 theme-header; + } + + .reading-content h5 { + @apply text-sm font-semibold mt-4 mb-2 theme-header; + } + .reading-content h6 { - @apply font-bold mt-8 mb-4 theme-header; + @apply text-xs font-semibold mt-4 mb-2 theme-header uppercase tracking-wide; } .reading-content p { @@ -118,4 +133,54 @@ .reading-content em { @apply italic; } + + /* Editor content styling - same as reading content but for the rich text editor */ + .editor-content h1 { + @apply text-2xl font-bold mt-8 mb-4 theme-header; + } + + .editor-content h2 { + @apply text-xl font-bold mt-6 mb-3 theme-header; + } + + .editor-content h3 { + @apply text-lg font-semibold mt-6 mb-3 theme-header; + } + + .editor-content h4 { + @apply text-base font-semibold mt-4 mb-2 theme-header; + } + + .editor-content h5 { + @apply text-sm font-semibold mt-4 mb-2 theme-header; + } + + .editor-content h6 { + @apply text-xs font-semibold mt-4 mb-2 theme-header uppercase tracking-wide; + } + + .editor-content p { + @apply mb-4 theme-text; + } + + .editor-content blockquote { + @apply border-l-4 pl-4 italic my-6 theme-border theme-text; + } + + .editor-content ul, + .editor-content ol { + @apply mb-4 pl-6 theme-text; + } + + .editor-content li { + @apply mb-2; + } + + .editor-content strong { + @apply font-semibold theme-header; + } + + .editor-content em { + @apply italic; + } } \ No newline at end of file diff --git a/frontend/src/app/stories/[id]/detail/page.tsx b/frontend/src/app/stories/[id]/detail/page.tsx index 54afd77..56a6fab 100644 --- a/frontend/src/app/stories/[id]/detail/page.tsx +++ b/frontend/src/app/stories/[id]/detail/page.tsx @@ -10,6 +10,7 @@ import AppLayout from '../../../../components/layout/AppLayout'; import Button from '../../../../components/ui/Button'; import LoadingSpinner from '../../../../components/ui/LoadingSpinner'; import TagDisplay from '../../../../components/tags/TagDisplay'; +import TableOfContents from '../../../../components/stories/TableOfContents'; import { calculateReadingTime } from '../../../../lib/settings'; export default function StoryDetailPage() { @@ -366,6 +367,15 @@ export default function StoryDetailPage() { )} + {/* Table of Contents */} + { + // Scroll to the story reading view with the specific heading + window.location.href = `/stories/${story.id}#${item.id}`; + }} + /> + {/* Tags */} {story.tags && story.tags.length > 0 && (
diff --git a/frontend/src/app/stories/[id]/page.tsx b/frontend/src/app/stories/[id]/page.tsx index 44ff335..7f21878 100644 --- a/frontend/src/app/stories/[id]/page.tsx +++ b/frontend/src/app/stories/[id]/page.tsx @@ -9,6 +9,7 @@ import LoadingSpinner from '../../../components/ui/LoadingSpinner'; import Button from '../../../components/ui/Button'; import StoryRating from '../../../components/stories/StoryRating'; import TagDisplay from '../../../components/tags/TagDisplay'; +import TableOfContents from '../../../components/stories/TableOfContents'; import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization'; export default function StoryReadingPage() { @@ -21,6 +22,8 @@ export default function StoryReadingPage() { const [readingProgress, setReadingProgress] = useState(0); const [sanitizedContent, setSanitizedContent] = useState(''); const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false); + const [showToc, setShowToc] = useState(false); + const [hasHeadings, setHasHeadings] = useState(false); const contentRef = useRef(null); const saveTimeoutRef = useRef(null); @@ -112,9 +115,20 @@ export default function StoryReadingPage() { setStory(storyData); - // Sanitize story content + // Sanitize story content and add IDs to headings const sanitized = await sanitizeHtml(storyData.contentHtml || ''); - setSanitizedContent(sanitized); + + // Parse and add IDs to headings for TOC functionality + const parser = new DOMParser(); + const doc = parser.parseFromString(sanitized, 'text/html'); + const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6'); + + headings.forEach((heading, index) => { + heading.id = `heading-${index}`; + }); + + setSanitizedContent(doc.body.innerHTML); + setHasHeadings(headings.length > 0); // Load series stories if part of a series if (storyData.seriesId) { @@ -134,12 +148,29 @@ export default function StoryReadingPage() { } }, [storyId]); - // Auto-scroll to saved reading position when story content is loaded + // Auto-scroll to saved reading position or URL hash when story content is loaded useEffect(() => { if (story && sanitizedContent && !hasScrolledToPosition) { // Use a small delay to ensure content is rendered const timeout = setTimeout(() => { console.log('Initializing reading position tracking, saved position:', story.readingPosition); + + // Check if there's a hash in the URL (for TOC navigation) + const hash = window.location.hash.substring(1); + if (hash && hash.startsWith('heading-')) { + console.log('Auto-scrolling to heading from URL hash:', hash); + const element = document.getElementById(hash); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + setHasScrolledToPosition(true); + return; + } + } + + // Otherwise, use saved reading position if (story.readingPosition && story.readingPosition > 0) { console.log('Auto-scrolling to saved position:', story.readingPosition); scrollToCharacterPosition(story.readingPosition); @@ -266,6 +297,16 @@ export default function StoryReadingPage() {
+ {hasHeadings && ( + + )} + + {/* Table of Contents Modal */} + {showToc && ( + <> + {/* Backdrop */} +
setShowToc(false)} + /> + + {/* TOC Modal */} +
+ { + const element = document.getElementById(item.id); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + setShowToc(false); // Close TOC after navigation + } + }} + /> +
+ + )} + {/* Story Content */}
diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index 5061895..91f9104 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -694,7 +694,7 @@ export default function RichTextEditor({ contentEditable onInput={handleVisualContentChange} onPaste={handlePaste} - className="p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none" + className="editor-content p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none" suppressContentEditableWarning={true} /> {!value && ( @@ -732,7 +732,7 @@ export default function RichTextEditor({

Preview:

diff --git a/frontend/src/components/stories/TableOfContents.tsx b/frontend/src/components/stories/TableOfContents.tsx new file mode 100644 index 0000000..52069fa --- /dev/null +++ b/frontend/src/components/stories/TableOfContents.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export interface TocItem { + id: string; + level: number; + title: string; + element?: HTMLElement; +} + +interface TableOfContentsProps { + htmlContent: string; + className?: string; + collapsible?: boolean; + defaultCollapsed?: boolean; + onItemClick?: (item: TocItem) => void; +} + +export default function TableOfContents({ + htmlContent, + className = '', + collapsible = false, + defaultCollapsed = false, + onItemClick +}: TableOfContentsProps) { + const [tocItems, setTocItems] = useState([]); + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); + + useEffect(() => { + // Parse HTML content to extract headings + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlContent, 'text/html'); + const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6'); + + const items: TocItem[] = []; + + headings.forEach((heading, index) => { + const level = parseInt(heading.tagName.charAt(1)); + let title = heading.textContent?.trim() || ''; + const id = `heading-${index}`; + + // Add ID to heading for later reference + heading.id = id; + + // Handle empty headings with a fallback + if (!title) { + title = `Heading ${index + 1}`; + } + + // Limit title length for display + if (title.length > 60) { + title = title.substring(0, 57) + '...'; + } + + items.push({ + id, + level, + title, + element: heading as HTMLElement + }); + }); + + setTocItems(items); + }, [htmlContent]); + + const handleItemClick = (item: TocItem) => { + if (onItemClick) { + onItemClick(item); + } else { + // Default behavior: smooth scroll to heading + const element = document.getElementById(item.id); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + } + }; + + const getIndentClass = (level: number) => { + switch (level) { + case 1: return 'pl-0'; + case 2: return 'pl-4'; + case 3: return 'pl-8'; + case 4: return 'pl-12'; + case 5: return 'pl-16'; + case 6: return 'pl-20'; + default: return 'pl-0'; + } + }; + + const getFontSizeClass = (level: number) => { + switch (level) { + case 1: return 'text-base font-semibold'; + case 2: return 'text-sm font-medium'; + case 3: return 'text-sm'; + case 4: return 'text-xs'; + case 5: return 'text-xs'; + case 6: return 'text-xs'; + default: return 'text-sm'; + } + }; + + if (tocItems.length === 0) { + // Don't render anything if no headings are found + return null; + } + + return ( +
+ {collapsible && ( + + )} + + {!collapsible && ( +
+

Table of Contents

+
+ )} + + {(!collapsible || !isCollapsed) && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file