diff --git a/README.md b/README.md index 3821032..0230df8 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,12 @@ cd backend ### 🎨 **User Experience** - **Dark/Light Mode**: Automatic theme switching with system preference detection - **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 - **Rich Text Editor**: Visual and source editing modes for story content +- **Progress Indicators**: Visual reading progress bars and completion tracking ### 🔒 **Security & Administration** - **JWT Authentication**: Secure token-based authentication @@ -170,9 +173,9 @@ StoryCove uses a PostgreSQL database with the following core entities: ### **Stories** - **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 -- **Features**: Automatic word count calculation, HTML sanitization, plain text extraction +- **Features**: Automatic word count calculation, HTML sanitization, plain text extraction, reading progress tracking ### **Authors** - **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}/tags/{tagId}` - Add tag to 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 /author/{authorId}` - Stories by author - `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 - **Infrastructure**: Docker, Docker Compose, Nginx - **Security**: JWT authentication, HTML sanitization, CORS +- **Search**: Typesense with faceting and full-text search capabilities ### **Local Development Setup** diff --git a/frontend/src/app/stories/[id]/page.tsx b/frontend/src/app/stories/[id]/page.tsx index 4156381..f56850f 100644 --- a/frontend/src/app/stories/[id]/page.tsx +++ b/frontend/src/app/stories/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { storyApi, seriesApi } from '../../../lib/api'; @@ -19,9 +19,85 @@ export default function StoryReadingPage() { const [error, setError] = useState(null); const [readingProgress, setReadingProgress] = useState(0); const [sanitizedContent, setSanitizedContent] = useState(''); + const [hasScrolledToPosition, setHasScrolledToPosition] = 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; + 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(() => { const loadStory = async () => { try { @@ -57,7 +133,27 @@ export default function StoryReadingPage() { } }, [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(() => { const handleScroll = () => { const article = document.querySelector('[data-reading-content]') as HTMLElement; @@ -72,12 +168,27 @@ export default function StoryReadingPage() { )); 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); - return () => window.removeEventListener('scroll', handleScroll); - }, [story]); + return () => { + window.removeEventListener('scroll', handleScroll); + // Clean up timeout on unmount + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]); const handleRatingUpdate = async (newRating: number) => { if (!story) return; @@ -229,6 +340,7 @@ export default function StoryReadingPage() { {/* Story Content */}
diff --git a/frontend/src/components/collections/CollectionReadingView.tsx b/frontend/src/components/collections/CollectionReadingView.tsx index 7e681d7..4be38ff 100644 --- a/frontend/src/components/collections/CollectionReadingView.tsx +++ b/frontend/src/components/collections/CollectionReadingView.tsx @@ -1,6 +1,8 @@ 'use client'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { StoryWithCollectionContext } from '../../types/api'; +import { storyApi } from '../../lib/api'; import Button from '../ui/Button'; import Link from 'next/link'; @@ -16,6 +18,120 @@ export default function CollectionReadingView({ onBackToCollection }: CollectionReadingViewProps) { const { story, collection } = data; + const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false); + const contentRef = useRef(null); + const saveTimeoutRef = useRef(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 = () => { if (collection.previousStoryId) { @@ -180,6 +296,7 @@ export default function CollectionReadingView({ {/* Story Content */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e641554..63b473b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -133,6 +133,11 @@ export const storyApi = { updateRating: async (id: string, rating: number): Promise => { await api.post(`/stories/${id}/rating`, { rating }); }, + + updateReadingProgress: async (id: string, position: number): Promise => { + const response = await api.post(`/stories/${id}/reading-progress`, { position }); + return response.data; + }, uploadCover: async (id: string, coverImage: File): Promise<{ imagePath: string }> => { const formData = new FormData(); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e195609..20c7ceb 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -15,6 +15,8 @@ export interface Story { coverPath?: string; tags: Tag[]; tagNames?: string[] | null; // Used in search results + readingPosition?: number; + lastReadAt?: string; createdAt: string; updatedAt: string; } diff --git a/storycove-spec.md b/storycove-spec.md index 61f993e..f338b66 100644 --- a/storycove-spec.md +++ b/storycove-spec.md @@ -69,6 +69,8 @@ CREATE TABLE stories ( volume INTEGER, rating INTEGER CHECK (rating >= 1 AND rating <= 5), 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, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (author_id) REFERENCES authors(id), @@ -140,7 +142,7 @@ CREATE TABLE story_tags ( {"name": "summary", "type": "string", "optional": true}, {"name": "author_name", "type": "string"}, {"name": "content", "type": "string"}, - {"name": "tags", "type": "string[]"}, + {"name": "tagNames", "type": "string[]", "facet": true}, {"name": "series_name", "type": "string", "optional": true}, {"name": "word_count", "type": "int32"}, {"name": "rating", "type": "int32", "optional": true}, @@ -178,6 +180,7 @@ Query parameters: - `tags` (string[]): Filter by tags - `authorId` (uuid): Filter by author - `seriesId` (uuid): Filter by series +- `facetBy` (string[]): Enable faceting for specified fields (e.g., ['tagNames']) #### POST /api/stories ```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 #### GET /api/authors @@ -300,7 +318,7 @@ Get all stories in a series ordered by volume #### Story List View - Grid/List toggle - 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 - Pagination - Cover image thumbnails in grid view @@ -321,7 +339,9 @@ Get all stories in a series ordered by volume - Clean, distraction-free interface - Cover image display at the top (if available) - 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 - Quick access to rate story - Back to library button @@ -468,6 +488,8 @@ Get all stories in a series ordered by volume 2. On story delete: Remove from index 3. Batch reindex endpoint for maintenance 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 @@ -567,11 +589,11 @@ APP_PASSWORD=application_password_here ## 9. Phase 2 Roadmap -### 9.1 URL Content Grabbing +### 9.1 URL Content Grabbing ✅ IMPLEMENTED - Configurable scrapers for specific sites -- Site configuration stored in database -- Content extraction rules per site -- Image download and storage +- Site configuration stored in JSON files +- Content extraction rules per site (DeviantArt support added) +- Adaptive content extraction for varying HTML structures ### 9.2 Enhanced Image Processing & Optimization - **Multi-size generation during upload** @@ -623,7 +645,9 @@ APP_PASSWORD=application_password_here - Search interface ### 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 - Rating system