Reading Progress
This commit is contained in:
13
README.md
13
README.md
@@ -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**
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export const storyApi = {
|
|||||||
updateRating: async (id: string, rating: number): Promise<void> => {
|
updateRating: async (id: string, rating: number): Promise<void> => {
|
||||||
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user