layout enhancement. Reading position reset

This commit is contained in:
Stefan Hardegger
2025-09-16 09:34:27 +02:00
parent f92dcc5314
commit c92308c24a
5 changed files with 452 additions and 65 deletions

View File

@@ -23,6 +23,7 @@ export default function EditStoryPage() {
const [story, setStory] = useState<Story | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [resetingPosition, setResetingPosition] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [formData, setFormData] = useState({
@@ -201,6 +202,32 @@ export default function EditStoryPage() {
}
};
const handleResetReadingPosition = async () => {
if (!story || !confirm('Are you sure you want to reset the reading position to the beginning? This will remove your current place in the story.')) {
return;
}
try {
setResetingPosition(true);
await storyApi.updateReadingProgress(storyId, 0);
setStory(prev => prev ? { ...prev, readingPosition: 0 } : null);
// Show success feedback
setErrors({ resetSuccess: 'Reading position reset! The story will start from the beginning next time you read it.' });
// Clear success message after 4 seconds
setTimeout(() => {
setErrors(prev => {
const { resetSuccess, ...rest } = prev;
return rest;
});
}, 4000);
} catch (error) {
console.error('Failed to reset reading position:', error);
setErrors({ submit: 'Failed to reset reading position' });
} finally {
setResetingPosition(false);
}
};
const handleDelete = async () => {
if (!story || !confirm('Are you sure you want to delete this story? This action cannot be undone.')) {
return;
@@ -374,6 +401,38 @@ export default function EditStoryPage() {
placeholder="https://example.com/original-story-url"
/>
{/* Reading Position Reset Section */}
<div className="theme-card p-4 rounded-lg border theme-border">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium theme-header">Reading Position</h3>
<p className="text-sm theme-text mt-1">
{story?.readingPosition && story.readingPosition > 0
? `Currently saved at position ${story.readingPosition.toLocaleString()}`
: 'No reading position saved (story will start from the beginning)'
}
</p>
</div>
<Button
type="button"
variant="ghost"
onClick={handleResetReadingPosition}
loading={resetingPosition}
disabled={saving || !story?.readingPosition || story.readingPosition === 0}
className="text-orange-600 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300"
>
Reset to Beginning
</Button>
</div>
</div>
{/* Success Message */}
{errors.resetSuccess && (
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-green-800 dark:text-green-200">{errors.resetSuccess}</p>
</div>
)}
{/* Submit Error */}
{errors.submit && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">

View File

@@ -24,6 +24,9 @@ export default function StoryReadingPage() {
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);
@@ -194,13 +197,41 @@ export default function StoryReadingPage() {
const articleTop = article.offsetTop;
const articleHeight = article.scrollHeight;
const windowHeight = window.innerHeight;
const progress = Math.min(100, Math.max(0,
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) {
console.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress });
setHasReachedEnd(true);
setShowEndOfStoryPopup(true);
}
// Save reading position (debounced)
if (hasScrolledToPosition) { // Only save after initial auto-scroll
const characterPosition = getCharacterPositionFromScroll();
@@ -220,11 +251,11 @@ export default function StoryReadingPage() {
clearTimeout(saveTimeoutRef.current);
}
};
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition, hasReachedEnd]);
const handleRatingUpdate = async (newRating: number) => {
if (!story) return;
try {
await storyApi.updateRating(story.id, newRating);
setStory(prev => prev ? { ...prev, rating: newRating } : null);
@@ -233,6 +264,25 @@ export default function StoryReadingPage() {
}
};
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;
@@ -350,6 +400,47 @@ export default function StoryReadingPage() {
</>
)}
{/* 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>