'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; }) => { 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 (
); }); StoryContent.displayName = 'StoryContent'; export default function StoryReadingPage() { const params = useParams(); const router = useRouter(); const [story, setStory] = useState(null); const [seriesStories, setSeriesStories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [readingProgress, setReadingProgress] = useState(0); const [readingPercentage, setReadingPercentage] = useState(0); const [sanitizedContent, setSanitizedContent] = useState(''); 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(null); const saveTimeoutRef = useRef(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(/]*>/gi); let headingCount = 0; if (headingMatches) { processedContent = processedContent.replace(/]*)>/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 ``; } }); } 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 (
); } if (error || !story) { return (

{error || 'Story not found'}

); } return (
{/* Progress Bar */}
{/* Header */}
← Library 📄 Details
{/* Reading percentage indicator */}
{readingPercentage}%
{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 } }} />
)} {/* End of Story Popup */} {showEndOfStoryPopup && ( <> {/* Backdrop */}
setShowEndOfStoryPopup(false)} /> {/* Popup Modal */}

🎉 Story Complete!

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?

)} {/* Story Content */}
{/* Title and Metadata */}

{story.title}

by {story.authorName}
{story.wordCount.toLocaleString()} words {new Date(story.createdAt).toLocaleDateString()} {story.seriesName && ( <> {story.seriesName} #{story.volume} )}
{/* Tags */} {story.tags && story.tags.length > 0 && (
{story.tags.map((tag) => ( ))}
)} {/* Source URL */} {story.sourceUrl && ( )}
{/* Story Content */}
{/* Series Navigation */} {(previousStory || nextStory) && ( )} {/* Back to Library */}
); }