669 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|