From c92308c24ac70a79269b08c5e421f31c19e7052b Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Tue, 16 Sep 2025 09:34:27 +0200 Subject: [PATCH] layout enhancement. Reading position reset --- frontend/src/app/stories/[id]/edit/page.tsx | 59 +++++ frontend/src/app/stories/[id]/page.tsx | 101 ++++++++- .../src/components/library/MinimalLayout.tsx | 10 +- .../src/components/library/SidebarLayout.tsx | 209 +++++++++++++++++- .../src/components/library/ToolbarLayout.tsx | 138 ++++++++---- 5 files changed, 452 insertions(+), 65 deletions(-) diff --git a/frontend/src/app/stories/[id]/edit/page.tsx b/frontend/src/app/stories/[id]/edit/page.tsx index 56ebb0c..bc59b66 100644 --- a/frontend/src/app/stories/[id]/edit/page.tsx +++ b/frontend/src/app/stories/[id]/edit/page.tsx @@ -23,6 +23,7 @@ export default function EditStoryPage() { const [story, setStory] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [resetingPosition, setResetingPosition] = useState(false); const [errors, setErrors] = useState>({}); 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 */} +
+
+
+

Reading Position

+

+ {story?.readingPosition && story.readingPosition > 0 + ? `Currently saved at position ${story.readingPosition.toLocaleString()}` + : 'No reading position saved (story will start from the beginning)' + } +

+
+ +
+
+ + {/* Success Message */} + {errors.resetSuccess && ( +
+

{errors.resetSuccess}

+
+ )} + {/* Submit Error */} {errors.submit && (
diff --git a/frontend/src/app/stories/[id]/page.tsx b/frontend/src/app/stories/[id]/page.tsx index 7f21878..58b72c0 100644 --- a/frontend/src/app/stories/[id]/page.tsx +++ b/frontend/src/app/stories/[id]/page.tsx @@ -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(null); const saveTimeoutRef = useRef(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 */} +
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 */}
diff --git a/frontend/src/components/library/MinimalLayout.tsx b/frontend/src/components/library/MinimalLayout.tsx index 7a9898b..d0b5d9f 100644 --- a/frontend/src/components/library/MinimalLayout.tsx +++ b/frontend/src/components/library/MinimalLayout.tsx @@ -237,9 +237,9 @@ export default function MinimalLayout({ />
-
+
{filteredTags.length === 0 && tagSearch ? ( -
+
No tags match "{tagSearch}"
) : ( @@ -251,9 +251,9 @@ export default function MinimalLayout({ selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : '' }`} > - diff --git a/frontend/src/components/library/SidebarLayout.tsx b/frontend/src/components/library/SidebarLayout.tsx index b193d6c..06dfd8f 100644 --- a/frontend/src/components/library/SidebarLayout.tsx +++ b/frontend/src/components/library/SidebarLayout.tsx @@ -62,9 +62,113 @@ export default function SidebarLayout({ ).length; return ( -
- {/* Left Sidebar */} -
+
+ {/* Mobile Header - Only shown on mobile */} +
+
+
+

Your Library

+

{totalElements} stories total

+
+ +
+ + {/* Mobile Search */} +
+ +
+ + {/* Mobile Controls Row */} +
+ {/* View Toggle */} +
+ + +
+ + {/* Sort */} + + + {/* Filter Toggle */} + +
+ + {/* Mobile Tag Pills - Show selected tags */} + {selectedTags.length > 0 && ( +
+ {selectedTags.slice(0, 3).map((tagName) => { + const tag = tags.find(t => t.name === tagName); + return tag ? ( +
onTagToggle(tag.name)} className="cursor-pointer"> + +
+ ) : null; + })} + {selectedTags.length > 3 && ( + +{selectedTags.length - 3} more + )} +
+ )} +
+ + {/* Left Sidebar - Hidden on mobile by default */} +
{/* Random Story Button */}
- + {/* Advanced Filters Toggle */} {onAdvancedFiltersChange && (
- + {/* Advanced Filters Section */} {showAdvancedFilters && onAdvancedFiltersChange && (
@@ -236,8 +340,95 @@ export default function SidebarLayout({
+ {/* Mobile Filter Panel - Shows when filters expanded */} + {showAdvancedFilters && ( +
+
+
+

Filters

+ +
+ + {/* Tag Grid */} +
+

Tags

+
+ setTagSearch(e.target.value)} + className="w-full px-2 py-1 text-sm border rounded theme-card border-gray-300 dark:border-gray-600" + /> +
+
+
+ + {filteredTags.slice(0, 19).map((tag) => ( + + ))} +
+
+
+ + {/* Advanced Filters */} + {onAdvancedFiltersChange && ( +
+

Advanced Filters

+ onAdvancedFiltersChange({})} + className="space-y-3" + /> +
+ )} + +
+ + +
+
+
+ )} + {/* Main Content */} -
+
{children}
diff --git a/frontend/src/components/library/ToolbarLayout.tsx b/frontend/src/components/library/ToolbarLayout.tsx index 2d35c90..a95587a 100644 --- a/frontend/src/components/library/ToolbarLayout.tsx +++ b/frontend/src/components/library/ToolbarLayout.tsx @@ -98,7 +98,7 @@ export default function ToolbarLayout({
{/* Sort */} -
+
- {/* View Toggle & Clear */} -
+ {/* View Toggle, Advanced Filters & Clear */} +
- {(searchQuery || selectedTags.length > 0) && ( - + + {(searchQuery || selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && ( + )}
@@ -181,20 +212,31 @@ export default function ToolbarLayout({
))} - {/* Filter expand button with counts */} - + {/* More Tags Button */} + {remainingTagsCount > 0 && ( + + )}
Showing {stories.length} of {totalElements} stories @@ -207,32 +249,36 @@ export default function ToolbarLayout({ {/* Tab Navigation */}
@@ -255,9 +301,9 @@ export default function ToolbarLayout({ )}
-
+
{filteredRemainingTags.length === 0 && tagSearch ? ( -
+
No tags match "{tagSearch}"
) : ( @@ -269,9 +315,9 @@ export default function ToolbarLayout({ selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : '' }`} > -