Reading Progress

This commit is contained in:
Stefan Hardegger
2025-07-29 14:53:44 +02:00
parent 5746001c4a
commit 57859d7a84
6 changed files with 281 additions and 16 deletions

View File

@@ -131,9 +131,12 @@ cd backend
### 🎨 **User Experience** ### 🎨 **User Experience**
- **Dark/Light Mode**: Automatic theme switching with system preference detection - **Dark/Light Mode**: Automatic theme switching with system preference detection
- **Responsive Design**: Optimized for desktop, tablet, and mobile - **Responsive Design**: Optimized for desktop, tablet, and mobile
- **Reading Mode**: Distraction-free reading interface - **Reading Mode**: Distraction-free reading interface with real-time progress tracking
- **Reading Position Memory**: Character-based position tracking with smooth auto-scroll restoration
- **Smart Tag Filtering**: Dynamic tag filters with live story counts in library view
- **Keyboard Navigation**: Full keyboard accessibility - **Keyboard Navigation**: Full keyboard accessibility
- **Rich Text Editor**: Visual and source editing modes for story content - **Rich Text Editor**: Visual and source editing modes for story content
- **Progress Indicators**: Visual reading progress bars and completion tracking
### 🔒 **Security & Administration** ### 🔒 **Security & Administration**
- **JWT Authentication**: Secure token-based authentication - **JWT Authentication**: Secure token-based authentication
@@ -170,9 +173,9 @@ StoryCove uses a PostgreSQL database with the following core entities:
### **Stories** ### **Stories**
- **Primary Key**: UUID - **Primary Key**: UUID
- **Fields**: title, summary, description, content_html, content_plain, source_url, word_count, rating, volume, cover_path - **Fields**: title, summary, description, content_html, content_plain, source_url, word_count, rating, volume, cover_path, reading_position, last_read_at
- **Relationships**: Many-to-One with Author, Many-to-One with Series, Many-to-Many with Tags - **Relationships**: Many-to-One with Author, Many-to-One with Series, Many-to-Many with Tags
- **Features**: Automatic word count calculation, HTML sanitization, plain text extraction - **Features**: Automatic word count calculation, HTML sanitization, plain text extraction, reading progress tracking
### **Authors** ### **Authors**
- **Primary Key**: UUID - **Primary Key**: UUID
@@ -214,7 +217,8 @@ StoryCove uses a PostgreSQL database with the following core entities:
- `POST /{id}/rating` - Set story rating - `POST /{id}/rating` - Set story rating
- `POST /{id}/tags/{tagId}` - Add tag to story - `POST /{id}/tags/{tagId}` - Add tag to story
- `DELETE /{id}/tags/{tagId}` - Remove tag from story - `DELETE /{id}/tags/{tagId}` - Remove tag from story
- `GET /search` - Search stories (Typesense) - `POST /{id}/reading-progress` - Update reading position
- `GET /search` - Search stories (Typesense with faceting)
- `GET /search/suggestions` - Get search suggestions - `GET /search/suggestions` - Get search suggestions
- `GET /author/{authorId}` - Stories by author - `GET /author/{authorId}` - Stories by author
- `GET /series/{seriesId}` - Stories in series - `GET /series/{seriesId}` - Stories in series
@@ -295,6 +299,7 @@ All API endpoints use JSON format with proper HTTP status codes:
- **Backend**: Spring Boot 3, Java 21, PostgreSQL, Typesense - **Backend**: Spring Boot 3, Java 21, PostgreSQL, Typesense
- **Infrastructure**: Docker, Docker Compose, Nginx - **Infrastructure**: Docker, Docker Compose, Nginx
- **Security**: JWT authentication, HTML sanitization, CORS - **Security**: JWT authentication, HTML sanitization, CORS
- **Search**: Typesense with faceting and full-text search capabilities
### **Local Development Setup** ### **Local Development Setup**

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { storyApi, seriesApi } from '../../../lib/api'; import { storyApi, seriesApi } from '../../../lib/api';
@@ -19,9 +19,85 @@ export default function StoryReadingPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [readingProgress, setReadingProgress] = useState(0); const [readingProgress, setReadingProgress] = useState(0);
const [sanitizedContent, setSanitizedContent] = useState<string>(''); const [sanitizedContent, setSanitizedContent] = useState<string>('');
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const storyId = params.id as string; 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;
return Math.floor(scrollRatio * textLength);
}, [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;
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) {
console.log('Skipping save - no story or position unchanged:', { story: !!story, position, current: story?.readingPosition });
return;
}
console.log('Saving reading position:', position, 'for story:', story.id);
try {
const updatedStory = await storyApi.updateReadingProgress(story.id, position);
console.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(() => { useEffect(() => {
const loadStory = async () => { const loadStory = async () => {
try { try {
@@ -57,7 +133,27 @@ export default function StoryReadingPage() {
} }
}, [storyId]); }, [storyId]);
// Track reading progress // Auto-scroll to saved reading position when story content is loaded
useEffect(() => {
if (story && sanitizedContent && !hasScrolledToPosition) {
// Use a small delay to ensure content is rendered
const timeout = setTimeout(() => {
console.log('Initializing reading position tracking, saved position:', story.readingPosition);
if (story.readingPosition && story.readingPosition > 0) {
console.log('Auto-scrolling to saved position:', story.readingPosition);
scrollToCharacterPosition(story.readingPosition);
} else {
// Even if there's no saved position, mark as ready for tracking
console.log('No saved position, starting fresh tracking');
setHasScrolledToPosition(true);
}
}, 500);
return () => clearTimeout(timeout);
}
}, [story, sanitizedContent, scrollToCharacterPosition, hasScrolledToPosition]);
// Track reading progress and save position
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
const article = document.querySelector('[data-reading-content]') as HTMLElement; const article = document.querySelector('[data-reading-content]') as HTMLElement;
@@ -72,12 +168,27 @@ export default function StoryReadingPage() {
)); ));
setReadingProgress(progress); setReadingProgress(progress);
// Save reading position (debounced)
if (hasScrolledToPosition) { // Only save after initial auto-scroll
const characterPosition = getCharacterPositionFromScroll();
console.log('Scroll detected, character position:', characterPosition);
debouncedSavePosition(characterPosition);
} else {
console.log('Scroll detected but not ready for tracking yet');
}
} }
}; };
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll); return () => {
}, [story]); window.removeEventListener('scroll', handleScroll);
// Clean up timeout on unmount
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
const handleRatingUpdate = async (newRating: number) => { const handleRatingUpdate = async (newRating: number) => {
if (!story) return; if (!story) return;
@@ -229,6 +340,7 @@ export default function StoryReadingPage() {
{/* Story Content */} {/* Story Content */}
<div <div
ref={contentRef}
className="reading-content" className="reading-content"
dangerouslySetInnerHTML={{ __html: sanitizedContent }} dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/> />

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { StoryWithCollectionContext } from '../../types/api'; import { StoryWithCollectionContext } from '../../types/api';
import { storyApi } from '../../lib/api';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Link from 'next/link'; import Link from 'next/link';
@@ -16,6 +18,120 @@ export default function CollectionReadingView({
onBackToCollection onBackToCollection
}: CollectionReadingViewProps) { }: CollectionReadingViewProps) {
const { story, collection } = data; const { story, collection } = data;
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 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;
return Math.floor(scrollRatio * textLength);
}, [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;
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) {
console.log('Collection view - skipping save - no story or position unchanged:', { story: !!story, position, current: story?.readingPosition });
return;
}
console.log('Collection view - saving reading position:', position, 'for story:', story.id);
try {
await storyApi.updateReadingProgress(story.id, position);
console.log('Collection view - reading position saved successfully');
} catch (error) {
console.error('Collection view - 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);
}, [saveReadingPosition]);
// Auto-scroll to saved reading position when story content is loaded
useEffect(() => {
if (story && !hasScrolledToPosition) {
const timeout = setTimeout(() => {
console.log('Collection view - initializing reading position tracking, saved position:', story.readingPosition);
if (story.readingPosition && story.readingPosition > 0) {
console.log('Collection view - auto-scrolling to saved position:', story.readingPosition);
scrollToCharacterPosition(story.readingPosition);
} else {
console.log('Collection view - no saved position, starting fresh tracking');
setHasScrolledToPosition(true);
}
}, 500);
return () => clearTimeout(timeout);
}
}, [story, scrollToCharacterPosition, hasScrolledToPosition]);
// Track reading progress and save position
useEffect(() => {
const handleScroll = () => {
if (hasScrolledToPosition) {
const characterPosition = getCharacterPositionFromScroll();
console.log('Collection view - scroll detected, character position:', characterPosition);
debouncedSavePosition(characterPosition);
} else {
console.log('Collection view - scroll detected but not ready for tracking yet');
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
const handlePrevious = () => { const handlePrevious = () => {
if (collection.previousStoryId) { if (collection.previousStoryId) {
@@ -180,6 +296,7 @@ export default function CollectionReadingView({
{/* Story Content */} {/* Story Content */}
<div className="theme-card p-8"> <div className="theme-card p-8">
<div <div
ref={contentRef}
className="prose prose-lg max-w-none theme-text" className="prose prose-lg max-w-none theme-text"
dangerouslySetInnerHTML={{ __html: story.contentHtml }} dangerouslySetInnerHTML={{ __html: story.contentHtml }}
/> />

View File

@@ -134,6 +134,11 @@ export const storyApi = {
await api.post(`/stories/${id}/rating`, { rating }); await api.post(`/stories/${id}/rating`, { rating });
}, },
updateReadingProgress: async (id: string, position: number): Promise<Story> => {
const response = await api.post(`/stories/${id}/reading-progress`, { position });
return response.data;
},
uploadCover: async (id: string, coverImage: File): Promise<{ imagePath: string }> => { uploadCover: async (id: string, coverImage: File): Promise<{ imagePath: string }> => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', coverImage); formData.append('file', coverImage);

View File

@@ -15,6 +15,8 @@ export interface Story {
coverPath?: string; coverPath?: string;
tags: Tag[]; tags: Tag[];
tagNames?: string[] | null; // Used in search results tagNames?: string[] | null; // Used in search results
readingPosition?: number;
lastReadAt?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -69,6 +69,8 @@ CREATE TABLE stories (
volume INTEGER, volume INTEGER,
rating INTEGER CHECK (rating >= 1 AND rating <= 5), rating INTEGER CHECK (rating >= 1 AND rating <= 5),
cover_image_path VARCHAR(500), -- Phase 2: Consider storing base filename without size suffix cover_image_path VARCHAR(500), -- Phase 2: Consider storing base filename without size suffix
reading_position INTEGER DEFAULT 0, -- Character position for reading progress
last_read_at TIMESTAMP, -- Last time story was accessed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES authors(id), FOREIGN KEY (author_id) REFERENCES authors(id),
@@ -140,7 +142,7 @@ CREATE TABLE story_tags (
{"name": "summary", "type": "string", "optional": true}, {"name": "summary", "type": "string", "optional": true},
{"name": "author_name", "type": "string"}, {"name": "author_name", "type": "string"},
{"name": "content", "type": "string"}, {"name": "content", "type": "string"},
{"name": "tags", "type": "string[]"}, {"name": "tagNames", "type": "string[]", "facet": true},
{"name": "series_name", "type": "string", "optional": true}, {"name": "series_name", "type": "string", "optional": true},
{"name": "word_count", "type": "int32"}, {"name": "word_count", "type": "int32"},
{"name": "rating", "type": "int32", "optional": true}, {"name": "rating", "type": "int32", "optional": true},
@@ -178,6 +180,7 @@ Query parameters:
- `tags` (string[]): Filter by tags - `tags` (string[]): Filter by tags
- `authorId` (uuid): Filter by author - `authorId` (uuid): Filter by author
- `seriesId` (uuid): Filter by series - `seriesId` (uuid): Filter by series
- `facetBy` (string[]): Enable faceting for specified fields (e.g., ['tagNames'])
#### POST /api/stories #### POST /api/stories
```json ```json
@@ -223,6 +226,21 @@ Request:
} }
``` ```
#### POST /api/stories/{id}/reading-progress
```json
Request:
{
"position": 1250
}
Response:
{
"id": "uuid",
"readingPosition": 1250,
"lastReadAt": "2024-01-01T12:00:00Z"
}
```
### 4.3 Author Endpoints ### 4.3 Author Endpoints
#### GET /api/authors #### GET /api/authors
@@ -300,7 +318,7 @@ Get all stories in a series ordered by volume
#### Story List View #### Story List View
- Grid/List toggle - Grid/List toggle
- Search bar with real-time results - Search bar with real-time results
- Tag cloud for filtering - Dynamic tag filtering with live story counts (faceted search)
- Sort options: Date added, Title, Author, Rating - Sort options: Date added, Title, Author, Rating
- Pagination - Pagination
- Cover image thumbnails in grid view - Cover image thumbnails in grid view
@@ -321,7 +339,9 @@ Get all stories in a series ordered by volume
- Clean, distraction-free interface - Clean, distraction-free interface
- Cover image display at the top (if available) - Cover image display at the top (if available)
- Responsive typography - Responsive typography
- Progress indicator - Real-time reading progress indicator with visual progress bar
- Character-based position tracking with automatic saving
- Automatic scroll-to-position restoration on story reopen
- Navigation: Previous/Next in series - Navigation: Previous/Next in series
- Quick access to rate story - Quick access to rate story
- Back to library button - Back to library button
@@ -468,6 +488,8 @@ Get all stories in a series ordered by volume
2. On story delete: Remove from index 2. On story delete: Remove from index
3. Batch reindex endpoint for maintenance 3. Batch reindex endpoint for maintenance
4. Search includes: title, author, content, tags 4. Search includes: title, author, content, tags
5. Faceted search support for dynamic filtering
6. Tag facets provide real-time counts for library filtering
### 6.4 Security Considerations ### 6.4 Security Considerations
@@ -567,11 +589,11 @@ APP_PASSWORD=application_password_here
## 9. Phase 2 Roadmap ## 9. Phase 2 Roadmap
### 9.1 URL Content Grabbing ### 9.1 URL Content Grabbing ✅ IMPLEMENTED
- Configurable scrapers for specific sites - Configurable scrapers for specific sites
- Site configuration stored in database - Site configuration stored in JSON files
- Content extraction rules per site - Content extraction rules per site (DeviantArt support added)
- Image download and storage - Adaptive content extraction for varying HTML structures
### 9.2 Enhanced Image Processing & Optimization ### 9.2 Enhanced Image Processing & Optimization
- **Multi-size generation during upload** - **Multi-size generation during upload**
@@ -623,7 +645,9 @@ APP_PASSWORD=application_password_here
- Search interface - Search interface
### Milestone 4: Reading Experience (Week 6) ### Milestone 4: Reading Experience (Week 6)
- Reading view implementation - Reading view implementation with progress tracking
- Character-based reading position persistence
- Automatic position restoration
- Settings management - Settings management
- Rating system - Rating system