Table of Content functionality

This commit is contained in:
Stefan Hardegger
2025-08-22 09:03:21 +02:00
parent a660056003
commit 15708b5ab2
5 changed files with 314 additions and 11 deletions

View File

@@ -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() {
</div>
)}
{/* Table of Contents */}
<TableOfContents
htmlContent={story.contentHtml || ''}
onItemClick={(item) => {
// 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 && (
<div className="theme-card theme-shadow rounded-lg p-4">

View File

@@ -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<string>('');
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
const [showToc, setShowToc] = useState(false);
const [hasHeadings, setHasHeadings] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(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() {
</div>
<div className="flex items-center gap-4">
{hasHeadings && (
<button
onClick={() => setShowToc(!showToc)}
className="text-sm theme-text hover:theme-accent transition-colors"
title="Table of Contents"
>
📋 TOC
</button>
)}
<StoryRating
rating={story.rating || 0}
onRatingChange={handleRatingUpdate}
@@ -280,6 +321,35 @@ export default function StoryReadingPage() {
</div>
</header>
{/* Table of Contents Modal */}
{showToc && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-50"
onClick={() => setShowToc(false)}
/>
{/* TOC Modal */}
<div className="fixed top-20 right-4 left-4 md:left-auto md:w-80 max-h-96 z-50">
<TableOfContents
htmlContent={sanitizedContent}
collapsible={false}
onItemClick={(item) => {
const element = document.getElementById(item.id);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
setShowToc(false); // Close TOC after navigation
}
}}
/>
</div>
</>
)}
{/* Story Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
<article data-reading-content>