Files
storycove/frontend/src/app/stories/[id]/page.tsx
2025-09-21 14:55:43 +02:00

669 lines
24 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { storyApi, seriesApi } from '../../../lib/api';
import { Story } from '../../../types/api';
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';
import { debug } from '../../../lib/debug';
// Memoized content component that only re-renders when content changes
const StoryContent = memo(({
content,
contentRef
}: {
content: string;
contentRef: React.RefObject<HTMLDivElement>;
}) => {
const renderTime = Date.now();
debug.log('🔄 StoryContent component rendering at', renderTime, 'with content length:', content.length, 'hash:', content.slice(0, 50) + '...');
// Add observer to track image loading events
useEffect(() => {
if (!contentRef.current) return;
const images = contentRef.current.querySelectorAll('img');
debug.log('📸 Found', images.length, 'images in content');
const handleImageLoad = (e: Event) => {
const img = e.target as HTMLImageElement;
debug.log('🖼️ Image loaded:', img.src);
};
const handleImageError = (e: Event) => {
const img = e.target as HTMLImageElement;
debug.log('❌ Image error:', img.src);
};
images.forEach(img => {
img.addEventListener('load', handleImageLoad);
img.addEventListener('error', handleImageError);
debug.log('👀 Monitoring image:', img.src);
});
return () => {
images.forEach(img => {
img.removeEventListener('load', handleImageLoad);
img.removeEventListener('error', handleImageError);
});
};
}, [content]);
return (
<div
ref={contentRef}
className="reading-content"
dangerouslySetInnerHTML={{ __html: content }}
style={{
// Prevent layout shifts that might cause image reloads
minHeight: '100vh',
contain: 'layout style'
}}
/>
);
});
StoryContent.displayName = 'StoryContent';
export default function StoryReadingPage() {
const params = useParams();
const router = useRouter();
const [story, setStory] = useState<Story | null>(null);
const [seriesStories, setSeriesStories] = useState<Story[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [readingProgress, setReadingProgress] = useState(0);
const [readingPercentage, setReadingPercentage] = useState(0);
const [sanitizedContent, setSanitizedContent] = useState<string>('');
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
const [showToc, setShowToc] = useState(false);
const [hasHeadings, setHasHeadings] = useState(false);
const [showEndOfStoryPopup, setShowEndOfStoryPopup] = useState(false);
const [hasReachedEnd, setHasReachedEnd] = useState(false);
const [resettingPosition, setResettingPosition] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const storyId = params.id as string;
// Convert scroll position to approximate character position in the content
const getCharacterPositionFromScroll = useCallback((): number => {
if (!contentRef.current || !story) return 0;
const content = contentRef.current;
const scrolled = window.scrollY;
const contentTop = content.offsetTop;
const contentHeight = content.scrollHeight;
const windowHeight = window.innerHeight;
// Calculate how far through the content we are (0-1)
const scrollRatio = Math.min(1, Math.max(0,
(scrolled - contentTop + windowHeight * 0.3) / contentHeight
));
// Convert to character position in the plain text content
const textLength = story.contentPlain?.length || story.contentHtml?.length || 0;
return Math.floor(scrollRatio * textLength);
}, [story]);
// Calculate reading percentage from character position
const calculateReadingPercentage = useCallback((currentPosition: number): number => {
if (!story) return 0;
const totalLength = story.contentPlain?.length || story.contentHtml?.length || 0;
if (totalLength === 0) return 0;
return Math.round((currentPosition / totalLength) * 100);
}, [story]);
// Convert character position back to scroll position for auto-scroll
const scrollToCharacterPosition = useCallback((position: number) => {
if (!contentRef.current || !story || hasScrolledToPosition) return;
const textLength = story.contentPlain?.length || story.contentHtml?.length || 0;
if (textLength === 0 || position === 0) return;
const ratio = position / textLength;
const content = contentRef.current;
const contentTop = content.offsetTop;
const contentHeight = content.scrollHeight;
const windowHeight = window.innerHeight;
// Calculate target scroll position
const targetScroll = contentTop + (ratio * contentHeight) - (windowHeight * 0.3);
// Smooth scroll to position
window.scrollTo({
top: Math.max(0, targetScroll),
behavior: 'smooth'
});
setHasScrolledToPosition(true);
}, [story, hasScrolledToPosition]);
// Debounced function to save reading position
const saveReadingPosition = useCallback(async (position: number) => {
if (!story || position === story.readingPosition) {
debug.log('Skipping save - no story or position unchanged:', { story: !!story, position, current: story?.readingPosition });
return;
}
debug.log('Saving reading position:', position, 'for story:', story.id);
try {
const updatedStory = await storyApi.updateReadingProgress(story.id, position);
debug.log('Reading position saved successfully, updated story:', updatedStory.readingPosition);
setStory(prev => prev ? { ...prev, readingPosition: position, lastReadAt: updatedStory.lastReadAt } : null);
} catch (error) {
console.error('Failed to save reading position:', error);
}
}, [story]);
// Debounced version of saveReadingPosition
const debouncedSavePosition = useCallback((position: number) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveReadingPosition(position);
}, 2000); // Save after 2 seconds of no scrolling
}, [saveReadingPosition]);
useEffect(() => {
const loadStory = async () => {
try {
setLoading(true);
// Preload sanitization config and load story in parallel
const [storyData] = await Promise.all([
storyApi.getStory(storyId),
preloadSanitizationConfig()
]);
setStory(storyData);
// Sanitize story content and add IDs to headings
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
// Add IDs to headings for TOC functionality using regex instead of DOMParser
// This avoids potential browser-specific sanitization that might strip src attributes
let processedContent = sanitized;
const headingMatches = processedContent.match(/<h[1-6][^>]*>/gi);
let headingCount = 0;
if (headingMatches) {
processedContent = processedContent.replace(/<h([1-6])([^>]*)>/gi, (match, level, attrs) => {
const headingId = `heading-${headingCount++}`;
// Check if id attribute already exists
if (attrs.includes('id=')) {
// Replace existing id
return match.replace(/id=['"][^'"]*['"]/, `id="${headingId}"`);
} else {
// Add id attribute
return `<h${level}${attrs} id="${headingId}">`;
}
});
}
setSanitizedContent(processedContent);
setHasHeadings(headingCount > 0);
// Load series stories if part of a series
if (storyData.seriesId) {
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
setSeriesStories(seriesData);
}
} catch (err: any) {
console.error('Failed to load story:', err);
setError(err.response?.data?.message || 'Failed to load story');
} finally {
setLoading(false);
}
};
if (storyId) {
loadStory();
}
}, [storyId]);
// 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(() => {
debug.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-')) {
debug.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) {
debug.log('Auto-scrolling to saved position:', story.readingPosition);
const initialPercentage = calculateReadingPercentage(story.readingPosition);
setReadingPercentage(initialPercentage);
scrollToCharacterPosition(story.readingPosition);
} else {
// Even if there's no saved position, mark as ready for tracking
debug.log('No saved position, starting fresh tracking');
setReadingPercentage(0);
setHasScrolledToPosition(true);
}
}, 500);
return () => clearTimeout(timeout);
}
}, [story, sanitizedContent, scrollToCharacterPosition, calculateReadingPercentage, hasScrolledToPosition]);
// Track reading progress and save position
useEffect(() => {
let ticking = false;
let scrollEventCount = 0;
const handleScroll = () => {
scrollEventCount++;
if (scrollEventCount % 10 === 0) {
debug.log('📜 Scroll event #', scrollEventCount, 'at', Date.now());
}
if (!ticking) {
requestAnimationFrame(() => {
const article = document.querySelector('[data-reading-content]') as HTMLElement;
if (article) {
const scrolled = window.scrollY;
const articleTop = article.offsetTop;
const articleHeight = article.scrollHeight;
const windowHeight = window.innerHeight;
const progress = Math.min(100, Math.max(0,
((scrolled - articleTop + windowHeight) / articleHeight) * 100
));
setReadingProgress(progress);
// Multi-method end-of-story detection
const documentHeight = document.documentElement.scrollHeight;
const windowBottom = scrolled + windowHeight;
const distanceFromBottom = documentHeight - windowBottom;
// Method 1: Distance from bottom (most reliable)
const nearBottom = distanceFromBottom <= 200;
// Method 2: High progress but only as secondary check
const highProgress = progress >= 98;
// Method 3: Check if story content itself is fully visible
const storyContentElement = contentRef.current;
let storyContentFullyVisible = false;
if (storyContentElement) {
const contentRect = storyContentElement.getBoundingClientRect();
const contentBottom = scrolled + contentRect.bottom;
const documentContentHeight = Math.max(documentHeight - 300, contentBottom); // Account for footer padding
storyContentFullyVisible = windowBottom >= documentContentHeight;
}
// Trigger end detection if user is near bottom AND (has high progress OR story content is fully visible)
if (nearBottom && (highProgress || storyContentFullyVisible) && !hasReachedEnd && hasScrolledToPosition) {
debug.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress });
setHasReachedEnd(true);
setShowEndOfStoryPopup(true);
}
// Save reading position and update percentage (debounced)
if (hasScrolledToPosition) { // Only save after initial auto-scroll
const characterPosition = getCharacterPositionFromScroll();
const percentage = calculateReadingPercentage(characterPosition);
debug.log('Scroll detected, character position:', characterPosition, 'percentage:', percentage);
setReadingPercentage(percentage);
debouncedSavePosition(characterPosition);
} else {
debug.log('Scroll detected but not ready for tracking yet');
}
}
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
// Clean up timeout on unmount
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, calculateReadingPercentage, debouncedSavePosition, hasReachedEnd]);
const handleRatingUpdate = async (newRating: number) => {
if (!story) return;
try {
await storyApi.updateRating(story.id, newRating);
setStory(prev => prev ? { ...prev, rating: newRating } : null);
} catch (error) {
console.error('Failed to update rating:', error);
}
};
const handleResetReadingPosition = async () => {
if (!story) return;
try {
setResettingPosition(true);
await storyApi.updateReadingProgress(story.id, 0);
setStory(prev => prev ? { ...prev, readingPosition: 0 } : null);
setShowEndOfStoryPopup(false);
setHasReachedEnd(false);
// DON'T scroll immediately - let user stay at current position
// The reset will take effect when they next open the story
} catch (error) {
console.error('Failed to reset reading position:', error);
} finally {
setResettingPosition(false);
}
};
const findNextStory = (): Story | null => {
if (!story?.seriesId || seriesStories.length <= 1) return null;
const currentIndex = seriesStories.findIndex(s => s.id === story.id);
return currentIndex < seriesStories.length - 1 ? seriesStories[currentIndex + 1] : null;
};
const findPreviousStory = (): Story | null => {
if (!story?.seriesId || seriesStories.length <= 1) return null;
const currentIndex = seriesStories.findIndex(s => s.id === story.id);
return currentIndex > 0 ? seriesStories[currentIndex - 1] : null;
};
const nextStory = findNextStory();
const previousStory = findPreviousStory();
// Memoize the sanitized content to prevent re-processing on scroll
const memoizedContent = useMemo(() => {
return sanitizedContent;
}, [sanitizedContent]);
if (loading) {
return (
<div className="min-h-screen theme-bg flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
if (error || !story) {
return (
<div className="min-h-screen theme-bg flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold theme-header mb-4">
{error || 'Story not found'}
</h1>
<Button href="/library">
Return to Library
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen theme-bg">
{/* Progress Bar */}
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-200 dark:bg-gray-700 z-50">
<div
className="h-full theme-accent-bg transition-all duration-200 ease-out"
style={{ width: `${readingProgress}%` }}
/>
</div>
{/* Header */}
<header className="sticky top-1 z-40 theme-card theme-shadow">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/library" className="theme-text hover:theme-accent">
Library
</Link>
<Link href={`/stories/${story.id}/detail`} className="theme-text hover:theme-accent">
📄 Details
</Link>
</div>
<div className="flex items-center gap-4">
{/* Reading percentage indicator */}
<div className="text-sm theme-text font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{readingPercentage}%
</div>
{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}
/>
<Link href={`/stories/${story.id}/edit`}>
<Button size="sm" variant="ghost">
Edit
</Button>
</Link>
</div>
</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>
</>
)}
{/* End of Story Popup */}
{showEndOfStoryPopup && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-50"
onClick={() => setShowEndOfStoryPopup(false)}
/>
{/* Popup Modal */}
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 max-w-md w-full mx-4">
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="text-center">
<h3 className="text-lg font-semibold theme-header mb-3">
🎉 Story Complete!
</h3>
<p className="theme-text mb-6">
You've reached the end of "{story?.title}". Would you like to reset your reading position so the story starts from the beginning next time you open it?
</p>
<div className="flex gap-3 justify-center">
<Button
variant="ghost"
onClick={() => setShowEndOfStoryPopup(false)}
>
Keep Current Position
</Button>
<Button
variant="primary"
onClick={handleResetReadingPosition}
loading={resettingPosition}
>
Reset for Next Time
</Button>
</div>
</div>
</div>
</div>
</>
)}
{/* Story Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
<article data-reading-content>
{/* Title and Metadata */}
<header className="mb-8 text-center">
<h1 className="text-4xl font-bold theme-header mb-4">
{story.title}
</h1>
<div className="space-y-2">
<Link
href={`/authors/${story.authorId}`}
className="text-xl theme-accent hover:underline"
>
by {story.authorName}
</Link>
<div className="flex justify-center items-center gap-4 text-sm theme-text">
<span>{story.wordCount.toLocaleString()} words</span>
<span>•</span>
<span>{new Date(story.createdAt).toLocaleDateString()}</span>
{story.seriesName && (
<>
<span>•</span>
<span>{story.seriesName} #{story.volume}</span>
</>
)}
</div>
</div>
{/* Tags */}
{story.tags && story.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mt-4">
{story.tags.map((tag) => (
<TagDisplay
key={tag.id}
tag={tag}
size="md"
clickable={false}
/>
))}
</div>
)}
{/* Source URL */}
{story.sourceUrl && (
<div className="mt-4">
<a
href={story.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm theme-accent hover:underline"
>
Original Source ↗
</a>
</div>
)}
</header>
{/* Story Content */}
<StoryContent
key={`story-content-${story?.id || 'loading'}`}
content={memoizedContent}
contentRef={contentRef}
/>
</article>
{/* Series Navigation */}
{(previousStory || nextStory) && (
<nav className="mt-12 pt-8 border-t theme-border">
<h3 className="text-lg font-semibold theme-header mb-4 text-center">
Continue Reading in {story.seriesName}
</h3>
<div className="flex justify-between items-center">
{previousStory ? (
<Link
href={`/stories/${previousStory.id}`}
className="flex-1 max-w-md p-4 theme-card theme-shadow rounded-lg hover:shadow-lg transition-shadow"
>
<div className="text-sm theme-text mb-1">← Previous</div>
<div className="font-semibold theme-header">{previousStory.title}</div>
<div className="text-sm theme-text">Part {previousStory.volume}</div>
</Link>
) : (
<div className="flex-1 max-w-md"></div>
)}
{nextStory ? (
<Link
href={`/stories/${nextStory.id}`}
className="flex-1 max-w-md p-4 theme-card theme-shadow rounded-lg hover:shadow-lg transition-shadow text-right"
>
<div className="text-sm theme-text mb-1">Next →</div>
<div className="font-semibold theme-header">{nextStory.title}</div>
<div className="text-sm theme-text">Part {nextStory.volume}</div>
</Link>
) : (
<div className="flex-1 max-w-md"></div>
)}
</div>
</nav>
)}
{/* Back to Library */}
<div className="text-center mt-12">
<Button href="/library" variant="ghost">
← Return to Library
</Button>
</div>
</main>
</div>
);
}