14 Commits

Author SHA1 Message Date
5e6236548d Merge branch 'main' into feature/collections 2025-07-25 14:22:42 +02:00
Stefan Hardegger
f068a6eb6f Adjust sanitation setting 2025-07-25 14:21:25 +02:00
Stefan Hardegger
312093ae2e Story Collections Feature 2025-07-25 14:15:23 +02:00
Stefan Hardegger
9dd8855914 Specification 2025-07-25 08:00:22 +02:00
Stefan Hardegger
6f478ab97a Fix Tag Filtering 2025-07-25 07:49:07 +02:00
Stefan Hardegger
12a8f2ee27 Bugfixes 2025-07-24 16:25:23 +02:00
Stefan Hardegger
a38812877a Fix order on pasting story 2025-07-24 14:51:20 +02:00
Stefan Hardegger
d48e217cbb Enhance Richtext editor 2025-07-24 13:35:47 +02:00
Stefan Hardegger
aae6091ef4 Enhance Richtext editor 2025-07-24 13:15:31 +02:00
Stefan Hardegger
131e2e8c25 Bugfixes 2025-07-24 13:07:36 +02:00
Stefan Hardegger
90428894b4 Improve Richtext Editor 2025-07-24 12:34:27 +02:00
Stefan Hardegger
a3f2801696 Fix author URL saving issue in multipart form submission
Changed frontend to send multiple URL parameters with same name ('urls')
instead of indexed parameters ('urls[0]', 'urls[1]'). Spring Boot expects
list parameters in multipart forms to use the same parameter name, not
array-style indexed naming.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 09:45:57 +02:00
Stefan Hardegger
8580d660e9 Update of documentation 2025-07-24 08:51:45 +02:00
Stefan Hardegger
77ad643eac configurable url 2025-07-24 08:03:56 +02:00
66 changed files with 9140 additions and 206 deletions

26
.env.development Normal file
View File

@@ -0,0 +1,26 @@
# Development Environment Configuration
# For local development with Docker
# Application URLs and CORS Configuration
STORYCOVE_PUBLIC_URL=http://localhost:6925
STORYCOVE_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:6925
# Database Configuration
DB_PASSWORD=dev_password
# JWT Configuration
JWT_SECRET=dev-jwt-secret-key-for-development-only
# Application Authentication
APP_PASSWORD=admin
# Typesense Search Configuration
TYPESENSE_API_KEY=dev_api_key
TYPESENSE_ENABLED=true
TYPESENSE_REINDEX_INTERVAL=3600000
# Image Storage
IMAGE_STORAGE_PATH=/app/images
# Frontend API URL (for local development)
NEXT_PUBLIC_API_URL=http://localhost:8080/api

View File

@@ -1,4 +1,26 @@
# StoryCove Environment Configuration
# Copy this file to .env and configure for your environment
# Application URLs and CORS Configuration
STORYCOVE_PUBLIC_URL=http://localhost:6925
STORYCOVE_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:6925
# Database Configuration
DB_PASSWORD=secure_password_here DB_PASSWORD=secure_password_here
# JWT Configuration
JWT_SECRET=secure_jwt_secret_here JWT_SECRET=secure_jwt_secret_here
TYPESENSE_API_KEY=secure_api_key_here
# Application Authentication
APP_PASSWORD=application_password_here APP_PASSWORD=application_password_here
# Typesense Search Configuration
TYPESENSE_API_KEY=secure_api_key_here
TYPESENSE_ENABLED=true
TYPESENSE_REINDEX_INTERVAL=3600000
# Image Storage
IMAGE_STORAGE_PATH=/app/images
# Frontend API URL (for development only - not used in Docker)
NEXT_PUBLIC_API_URL=http://localhost:8080/api

30
.env.production Normal file
View File

@@ -0,0 +1,30 @@
# Production Environment Configuration
# Set these values in your production environment
# Application URLs and CORS Configuration
# Replace with your actual production URL
STORYCOVE_PUBLIC_URL=https://storycove.hardegger.io
STORYCOVE_CORS_ALLOWED_ORIGINS=https://storycove.hardegger.io
# Database Configuration
# Use a strong password in production
DB_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
# JWT Configuration
# Use a strong, random secret in production
JWT_SECRET=REPLACE_WITH_SECURE_JWT_SECRET_MINIMUM_32_CHARS
# Application Authentication
# Use a strong password in production
APP_PASSWORD=REPLACE_WITH_SECURE_APP_PASSWORD
# Typesense Search Configuration
TYPESENSE_API_KEY=REPLACE_WITH_SECURE_TYPESENSE_API_KEY
TYPESENSE_ENABLED=true
TYPESENSE_REINDEX_INTERVAL=3600000
# Image Storage
IMAGE_STORAGE_PATH=/app/images
# Frontend API URL (not used in Docker, but can be set for external deployments)
NEXT_PUBLIC_API_URL=/api

27
.env.staging Normal file
View File

@@ -0,0 +1,27 @@
# Staging Environment Configuration
# For testing before production deployment
# Application URLs and CORS Configuration
# Replace with your actual staging URL
STORYCOVE_PUBLIC_URL=https://staging.yourdomain.com
STORYCOVE_CORS_ALLOWED_ORIGINS=https://staging.yourdomain.com
# Database Configuration
DB_PASSWORD=staging_secure_password
# JWT Configuration
JWT_SECRET=staging-jwt-secret-key-for-testing-32-chars-min
# Application Authentication
APP_PASSWORD=staging_admin_password
# Typesense Search Configuration
TYPESENSE_API_KEY=staging_typesense_api_key
TYPESENSE_ENABLED=true
TYPESENSE_REINDEX_INTERVAL=3600000
# Image Storage
IMAGE_STORAGE_PATH=/app/images
# Frontend API URL
NEXT_PUBLIC_API_URL=/api

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

321
README.md
View File

@@ -4,6 +4,24 @@ A self-hosted web application for storing, organizing, and reading short stories
## Quick Start ## Quick Start
### Option 1: Using Environment-Specific Deployment (Recommended)
1. Deploy for your environment:
```bash
# For development
./deploy.sh development
# For staging
./deploy.sh staging
# For production (edit .env.production first with your values)
./deploy.sh production
```
2. Edit the environment file (`.env.development`, `.env.staging`, or `.env.production`) with your specific configuration
### Option 2: Manual Configuration
1. Copy environment variables: 1. Copy environment variables:
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -16,7 +34,7 @@ cp .env.example .env
docker-compose up -d docker-compose up -d
``` ```
4. Access the application at http://localhost 4. Access the application at the URL specified in your environment configuration (default: http://localhost:6925)
## Architecture ## Architecture
@@ -26,6 +44,44 @@ docker-compose up -d
- **Search**: Typesense (Port 8108) - **Search**: Typesense (Port 8108)
- **Proxy**: Nginx (Port 80) - **Proxy**: Nginx (Port 80)
## Environment Configuration
StoryCove supports different deployment environments with specific configuration files:
### Environment Files
- `.env.development` - Local development with Docker
- `.env.staging` - Staging/testing environment
- `.env.production` - Production deployment
- `.env.example` - Template with all available options
### Key Environment Variables
- `STORYCOVE_PUBLIC_URL` - The public URL where your app is accessible
- `STORYCOVE_CORS_ALLOWED_ORIGINS` - Comma-separated list of allowed CORS origins
- `DB_PASSWORD` - PostgreSQL database password
- `JWT_SECRET` - Secret key for JWT token signing (minimum 32 characters)
- `APP_PASSWORD` - Application login password
- `TYPESENSE_API_KEY` - API key for Typesense search service
- `NEXT_PUBLIC_API_URL` - Frontend API URL (for development only)
### Deployment Examples
For a production deployment at `https://stories.example.com`:
```bash
# Edit .env.production
STORYCOVE_PUBLIC_URL=https://stories.example.com
STORYCOVE_CORS_ALLOWED_ORIGINS=https://stories.example.com
# Deploy
./deploy.sh production
```
For local development:
```bash
./deploy.sh development
```
## Development ## Development
### Frontend Development ### Frontend Development
@@ -47,14 +103,259 @@ cd backend
- `docker-compose logs -f [service]` - View logs - `docker-compose logs -f [service]` - View logs
- `docker-compose build` - Rebuild containers - `docker-compose build` - Rebuild containers
## Features ## Features
- Story management with HTML content support ### 📚 **Story Management**
- Author profiles with ratings and metadata - **Rich Text Support**: HTML content with automatic plain text extraction
- Tag-based categorization - **Content Sanitization**: Secure HTML processing with customizable sanitization rules
- Full-text search capabilities - **Metadata Management**: Title, summary, description, source URL tracking
- Responsive reading interface - **Rating System**: 5-star rating system for stories
- JWT-based authentication - **Word Count**: Automatic word count calculation
- Docker-based deployment - **Cover Images**: Upload and manage story cover images
- **Series Support**: Organize stories into series with volume numbers
For detailed specifications, see `storycove-spec.md`. ### 👤 **Author Management**
- **Author Profiles**: Comprehensive author information with notes
- **Avatar Images**: Upload and manage author profile pictures
- **URL Collections**: Track multiple URLs per author (websites, social media, etc.)
- **Author Ratings**: Rate authors with 5-star system
- **Statistics**: View author statistics and story counts
### 🏷️ **Organization & Discovery**
- **Tag System**: Flexible tagging with autocomplete
- **Series Organization**: Group related stories with volume ordering
- **Search & Filter**: Full-text search powered by Typesense
- **Advanced Filtering**: Filter by author, tags, ratings, and more
- **Smart Suggestions**: Auto-complete for tags and search queries
### 🎨 **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
- **Keyboard Navigation**: Full keyboard accessibility
- **Rich Text Editor**: Visual and source editing modes for story content
### 🔒 **Security & Administration**
- **JWT Authentication**: Secure token-based authentication
- **Single Password**: Simplified access control
- **HTML Sanitization**: Prevent XSS attacks with configurable sanitization
- **CORS Configuration**: Environment-specific CORS settings
- **File Upload Security**: Secure image upload with validation
### 🚀 **Technical Features**
- **Full-Text Search**: Powered by Typesense search engine
- **RESTful API**: Comprehensive REST API for all operations
- **Database Optimization**: PostgreSQL with proper indexing
- **Image Processing**: Automatic image optimization and storage
- **Environment Configuration**: Multi-environment deployment support
- **Docker Deployment**: Complete containerized deployment
### 📊 **Analytics & Statistics**
- **Usage Statistics**: Track story counts, tag usage, and more
- **Rating Analytics**: View average ratings and distributions
- **Search Analytics**: Monitor search performance and suggestions
- **Storage Metrics**: Track image storage and database usage
## 📖 Documentation
- **[API Documentation](docs/API.md)**: Complete REST API reference with examples
- **[Data Model](docs/DATA_MODEL.md)**: Detailed database schema and relationships
- **[Technical Specification](storycove-spec.md)**: Comprehensive technical specification
- **Environment Configuration**: Multi-environment deployment setup (see above)
- **Development Setup**: Local development environment setup (see below)
## 🗄️ Data Model
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
- **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
### **Authors**
- **Primary Key**: UUID
- **Fields**: name, notes, author_rating, avatar_image_path
- **Relationships**: One-to-Many with Stories, One-to-Many with Author URLs
- **Features**: URL collection storage, rating system, statistics calculation
### **Series**
- **Primary Key**: UUID
- **Fields**: name, description
- **Relationships**: One-to-Many with Stories (ordered by volume)
- **Features**: Volume-based story ordering, navigation methods
### **Tags**
- **Primary Key**: UUID
- **Fields**: name (unique)
- **Relationships**: Many-to-Many with Stories
- **Features**: Autocomplete support, usage statistics
### **Join Tables**
- **story_tags**: Links stories to tags
- **author_urls**: Stores multiple URLs per author
## 🔌 REST API Reference
### **Authentication** (`/api/auth`)
- `POST /login` - Authenticate with password
- `POST /logout` - Clear authentication token
- `GET /verify` - Verify token validity
### **Stories** (`/api/stories`)
- `GET /` - List stories (paginated)
- `GET /{id}` - Get specific story
- `POST /` - Create new story
- `PUT /{id}` - Update story
- `DELETE /{id}` - Delete story
- `POST /{id}/cover` - Upload cover image
- `DELETE /{id}/cover` - Remove cover image
- `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)
- `GET /search/suggestions` - Get search suggestions
- `GET /author/{authorId}` - Stories by author
- `GET /series/{seriesId}` - Stories in series
- `GET /tags/{tagName}` - Stories with tag
- `GET /recent` - Recent stories
- `GET /top-rated` - Top-rated stories
### **Authors** (`/api/authors`)
- `GET /` - List authors (paginated)
- `GET /{id}` - Get specific author
- `POST /` - Create new author
- `PUT /{id}` - Update author (JSON or multipart)
- `DELETE /{id}` - Delete author
- `POST /{id}/avatar` - Upload avatar image
- `DELETE /{id}/avatar` - Remove avatar image
- `POST /{id}/rating` - Set author rating
- `POST /{id}/urls` - Add URL to author
- `DELETE /{id}/urls` - Remove URL from author
- `GET /search` - Search authors
- `GET /search-typesense` - Advanced author search
- `GET /top-rated` - Top-rated authors
### **Tags** (`/api/tags`)
- `GET /` - List tags (paginated)
- `GET /{id}` - Get specific tag
- `POST /` - Create new tag
- `PUT /{id}` - Update tag
- `DELETE /{id}` - Delete tag
- `GET /search` - Search tags
- `GET /autocomplete` - Tag autocomplete
- `GET /popular` - Most used tags
- `GET /unused` - Unused tags
- `GET /stats` - Tag statistics
### **Series** (`/api/series`)
- `GET /` - List series (paginated)
- `GET /{id}` - Get specific series
- `POST /` - Create new series
- `PUT /{id}` - Update series
- `DELETE /{id}` - Delete series
- `GET /search` - Search series
- `GET /with-stories` - Series with stories
- `GET /popular` - Popular series
- `GET /empty` - Empty series
- `GET /stats` - Series statistics
### **Files** (`/api/files`)
- `POST /upload/cover` - Upload cover image
- `POST /upload/avatar` - Upload avatar image
- `GET /images/**` - Serve image files
- `DELETE /images` - Delete image file
### **Search** (`/api/search`)
- `POST /reindex` - Reindex all content
- `GET /health` - Search service health
### **Configuration** (`/api/config`)
- `GET /html-sanitization` - Get HTML sanitization rules
### **Request/Response Format**
All API endpoints use JSON format with proper HTTP status codes:
- **200**: Success
- **201**: Created
- **400**: Bad Request (validation errors)
- **401**: Unauthorized
- **404**: Not Found
- **500**: Internal Server Error
### **Authentication**
- JWT tokens provided via httpOnly cookies
- All endpoints (except `/api/auth/login`) require authentication
- Tokens expire after 24 hours
## 🔧 Development
### **Technology Stack**
- **Frontend**: Next.js 14, TypeScript, Tailwind CSS, React
- **Backend**: Spring Boot 3, Java 21, PostgreSQL, Typesense
- **Infrastructure**: Docker, Docker Compose, Nginx
- **Security**: JWT authentication, HTML sanitization, CORS
### **Local Development Setup**
1. **Prerequisites**:
```bash
# Required
- Docker & Docker Compose
- Node.js 18+ (for frontend development)
- Java 21+ (for backend development)
```
2. **Environment Setup**:
```bash
# Copy environment template
cp .env.example .env
# Edit environment variables
vim .env
```
3. **Database Setup**:
```bash
# Start database only
docker-compose up -d postgres
```
4. **Backend Development**:
```bash
cd backend
./mvnw spring-boot:run
```
5. **Frontend Development**:
```bash
cd frontend
npm install
npm run dev
```
6. **Full Stack Development**:
```bash
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f
```
### **Testing**
```bash
# Backend tests
cd backend && ./mvnw test
# Frontend build
cd frontend && npm run build
# Frontend linting
cd frontend && npm run lint
```
### **Database Migrations**
The application uses Hibernate with `ddl-auto: update` for schema management. For production deployments, consider using Flyway or Liquibase for controlled migrations.
For detailed technical specifications, see `storycove-spec.md`.

View File

@@ -23,7 +23,7 @@ import java.util.List;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
@Value("${storycove.cors.allowed-origins:${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000}}") @Value("${storycove.cors.allowed-origins}")
private String allowedOrigins; private String allowedOrigins;
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;

View File

@@ -1,8 +1,6 @@
package com.storycove.controller; package com.storycove.controller;
import com.storycove.dto.AuthorDto; import com.storycove.dto.*;
import com.storycove.dto.AuthorSearchDto;
import com.storycove.dto.SearchResultDto;
import com.storycove.entity.Author; import com.storycove.entity.Author;
import com.storycove.service.AuthorService; import com.storycove.service.AuthorService;
import com.storycove.service.ImageService; import com.storycove.service.ImageService;
@@ -43,7 +41,7 @@ public class AuthorController {
} }
@GetMapping @GetMapping
public ResponseEntity<Page<AuthorDto>> getAllAuthors( public ResponseEntity<Page<AuthorSummaryDto>> getAllAuthors(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sortBy, @RequestParam(defaultValue = "name") String sortBy,
@@ -54,7 +52,7 @@ public class AuthorController {
Pageable pageable = PageRequest.of(page, size, sort); Pageable pageable = PageRequest.of(page, size, sort);
Page<Author> authors = authorService.findAll(pageable); Page<Author> authors = authorService.findAll(pageable);
Page<AuthorDto> authorDtos = authors.map(this::convertToDto); Page<AuthorSummaryDto> authorDtos = authors.map(this::convertToSummaryDto);
return ResponseEntity.ok(authorDtos); return ResponseEntity.ok(authorDtos);
} }
@@ -255,14 +253,14 @@ public class AuthorController {
} }
@GetMapping("/search") @GetMapping("/search")
public ResponseEntity<Page<AuthorDto>> searchAuthors( public ResponseEntity<Page<AuthorSummaryDto>> searchAuthors(
@RequestParam String query, @RequestParam String query,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size); Pageable pageable = PageRequest.of(page, size);
Page<Author> authors = authorService.searchByName(query, pageable); Page<Author> authors = authorService.searchByName(query, pageable);
Page<AuthorDto> authorDtos = authors.map(this::convertToDto); Page<AuthorSummaryDto> authorDtos = authors.map(this::convertToSummaryDto);
return ResponseEntity.ok(authorDtos); return ResponseEntity.ok(authorDtos);
} }
@@ -353,10 +351,10 @@ public class AuthorController {
} }
@GetMapping("/top-rated") @GetMapping("/top-rated")
public ResponseEntity<List<AuthorDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) { public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit); Pageable pageable = PageRequest.of(0, limit);
List<Author> authors = authorService.findTopRated(pageable); List<Author> authors = authorService.findTopRated(pageable);
List<AuthorDto> authorDtos = authors.stream().map(this::convertToDto).collect(Collectors.toList()); List<AuthorSummaryDto> authorDtos = authors.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
return ResponseEntity.ok(authorDtos); return ResponseEntity.ok(authorDtos);
} }
@@ -422,6 +420,24 @@ public class AuthorController {
return dto; return dto;
} }
private AuthorSummaryDto convertToSummaryDto(Author author) {
AuthorSummaryDto dto = new AuthorSummaryDto();
dto.setId(author.getId());
dto.setName(author.getName());
dto.setNotes(author.getNotes());
dto.setAvatarImagePath(author.getAvatarImagePath());
dto.setAuthorRating(author.getAuthorRating());
dto.setUrls(author.getUrls());
dto.setStoryCount(author.getStories() != null ? author.getStories().size() : 0);
dto.setCreatedAt(author.getCreatedAt());
dto.setUpdatedAt(author.getUpdatedAt());
// Calculate and set average story rating without loading all stories
dto.setAverageStoryRating(authorService.calculateAverageStoryRating(author.getId()));
return dto;
}
private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) { private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) {
AuthorDto dto = new AuthorDto(); AuthorDto dto = new AuthorDto();
dto.setId(searchDto.getId()); dto.setId(searchDto.getId());

View File

@@ -0,0 +1,421 @@
package com.storycove.controller;
import com.storycove.dto.*;
import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.service.CollectionService;
import com.storycove.service.ImageService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/collections")
public class CollectionController {
private static final Logger logger = LoggerFactory.getLogger(CollectionController.class);
private final CollectionService collectionService;
private final ImageService imageService;
@Autowired
public CollectionController(CollectionService collectionService,
ImageService imageService) {
this.collectionService = collectionService;
this.imageService = imageService;
}
/**
* GET /api/collections - Search and list collections with pagination
* IMPORTANT: Uses Typesense for all search/filter operations
*/
@GetMapping
public ResponseEntity<SearchResultDto<CollectionDto>> getCollections(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(required = false) String search,
@RequestParam(required = false) List<String> tags,
@RequestParam(defaultValue = "false") boolean archived) {
logger.info("COLLECTIONS: Search request - search='{}', tags={}, archived={}, page={}, limit={}",
search, tags, archived, page, limit);
// MANDATORY: Use Typesense for all search/filter operations
SearchResultDto<Collection> results = collectionService.searchCollections(search, tags, archived, page, limit);
// Convert to lightweight DTOs
SearchResultDto<CollectionDto> optimizedResults = new SearchResultDto<>();
optimizedResults.setQuery(results.getQuery());
optimizedResults.setPage(results.getPage());
optimizedResults.setPerPage(results.getPerPage());
optimizedResults.setTotalHits(results.getTotalHits());
optimizedResults.setSearchTimeMs(results.getSearchTimeMs());
if (results.getResults() != null) {
optimizedResults.setResults(results.getResults().stream()
.map(this::mapToCollectionDto)
.toList());
}
return ResponseEntity.ok(optimizedResults);
}
/**
* GET /api/collections/{id} - Get collection with lightweight details (no story content)
*/
@GetMapping("/{id}")
public ResponseEntity<CollectionDto> getCollectionById(@PathVariable UUID id) {
Collection collection = collectionService.findById(id);
CollectionDto dto = mapToCollectionDto(collection);
return ResponseEntity.ok(dto);
}
/**
* POST /api/collections - Create new collection
*/
@PostMapping
public ResponseEntity<Collection> createCollection(@Valid @RequestBody CreateCollectionRequest request) {
Collection collection = collectionService.createCollection(
request.getName(),
request.getDescription(),
request.getTagNames(),
request.getStoryIds()
);
return ResponseEntity.status(HttpStatus.CREATED).body(collection);
}
/**
* POST /api/collections (multipart) - Create new collection with cover image
*/
@PostMapping(consumes = "multipart/form-data")
public ResponseEntity<Collection> createCollectionWithImage(
@RequestParam String name,
@RequestParam(required = false) String description,
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) List<UUID> storyIds,
@RequestParam(required = false, name = "coverImage") MultipartFile coverImage) {
try {
// Create collection first
Collection collection = collectionService.createCollection(name, description, tags, storyIds);
// Upload cover image if provided
if (coverImage != null && !coverImage.isEmpty()) {
String imagePath = imageService.uploadImage(coverImage, ImageService.ImageType.COVER);
collection.setCoverImagePath(imagePath);
collection = collectionService.updateCollection(
collection.getId(), null, null, null, null
);
}
return ResponseEntity.status(HttpStatus.CREATED).body(collection);
} catch (Exception e) {
logger.error("Failed to create collection with image", e);
return ResponseEntity.badRequest().build();
}
}
/**
* PUT /api/collections/{id} - Update collection metadata
*/
@PutMapping("/{id}")
public ResponseEntity<Collection> updateCollection(
@PathVariable UUID id,
@Valid @RequestBody UpdateCollectionRequest request) {
Collection collection = collectionService.updateCollection(
id,
request.getName(),
request.getDescription(),
request.getTagNames(),
request.getRating()
);
return ResponseEntity.ok(collection);
}
/**
* DELETE /api/collections/{id} - Delete collection
*/
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> deleteCollection(@PathVariable UUID id) {
collectionService.deleteCollection(id);
return ResponseEntity.ok(Map.of("message", "Collection deleted successfully"));
}
/**
* PUT /api/collections/{id}/archive - Archive/unarchive collection
*/
@PutMapping("/{id}/archive")
public ResponseEntity<Collection> archiveCollection(
@PathVariable UUID id,
@RequestBody ArchiveRequest request) {
Collection collection = collectionService.archiveCollection(id, request.getArchived());
return ResponseEntity.ok(collection);
}
/**
* POST /api/collections/{id}/stories - Add stories to collection
*/
@PostMapping("/{id}/stories")
public ResponseEntity<Map<String, Object>> addStoriesToCollection(
@PathVariable UUID id,
@RequestBody AddStoriesRequest request) {
Map<String, Object> result = collectionService.addStoriesToCollection(
id,
request.getStoryIds(),
request.getPosition()
);
return ResponseEntity.ok(result);
}
/**
* DELETE /api/collections/{id}/stories/{storyId} - Remove story from collection
*/
@DeleteMapping("/{id}/stories/{storyId}")
public ResponseEntity<Map<String, String>> removeStoryFromCollection(
@PathVariable UUID id,
@PathVariable UUID storyId) {
collectionService.removeStoryFromCollection(id, storyId);
return ResponseEntity.ok(Map.of("message", "Story removed from collection"));
}
/**
* PUT /api/collections/{id}/stories/order - Reorder stories in collection
*/
@PutMapping("/{id}/stories/order")
public ResponseEntity<Map<String, String>> reorderStories(
@PathVariable UUID id,
@RequestBody ReorderStoriesRequest request) {
collectionService.reorderStories(id, request.getStoryOrders());
return ResponseEntity.ok(Map.of("message", "Stories reordered successfully"));
}
/**
* GET /api/collections/{id}/read/{storyId} - Get story with collection context
*/
@GetMapping("/{id}/read/{storyId}")
public ResponseEntity<Map<String, Object>> getStoryWithCollectionContext(
@PathVariable UUID id,
@PathVariable UUID storyId) {
Map<String, Object> result = collectionService.getStoryWithCollectionContext(id, storyId);
return ResponseEntity.ok(result);
}
/**
* GET /api/collections/{id}/stats - Get collection statistics
*/
@GetMapping("/{id}/stats")
public ResponseEntity<Map<String, Object>> getCollectionStatistics(@PathVariable UUID id) {
Map<String, Object> stats = collectionService.getCollectionStatistics(id);
return ResponseEntity.ok(stats);
}
/**
* POST /api/collections/{id}/cover - Upload cover image
*/
@PostMapping("/{id}/cover")
public ResponseEntity<Map<String, Object>> uploadCoverImage(
@PathVariable UUID id,
@RequestParam("file") MultipartFile file) {
try {
String imagePath = imageService.uploadImage(file, ImageService.ImageType.COVER);
// Update collection with new cover path
collectionService.updateCollection(id, null, null, null, null);
Collection collection = collectionService.findByIdBasic(id);
collection.setCoverImagePath(imagePath);
return ResponseEntity.ok(Map.of(
"message", "Cover uploaded successfully",
"coverPath", imagePath,
"coverUrl", "/api/files/images/" + imagePath
));
} catch (Exception e) {
logger.error("Failed to upload collection cover", e);
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
/**
* DELETE /api/collections/{id}/cover - Remove cover image
*/
@DeleteMapping("/{id}/cover")
public ResponseEntity<Map<String, String>> removeCoverImage(@PathVariable UUID id) {
Collection collection = collectionService.findByIdBasic(id);
collection.setCoverImagePath(null);
collectionService.updateCollection(id, null, null, null, null);
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
}
// Mapper methods
private CollectionDto mapToCollectionDto(Collection collection) {
CollectionDto dto = new CollectionDto();
dto.setId(collection.getId());
dto.setName(collection.getName());
dto.setDescription(collection.getDescription());
dto.setRating(collection.getRating());
dto.setCoverImagePath(collection.getCoverImagePath());
dto.setIsArchived(collection.getIsArchived());
dto.setCreatedAt(collection.getCreatedAt());
dto.setUpdatedAt(collection.getUpdatedAt());
// Map tags
if (collection.getTags() != null) {
dto.setTags(collection.getTags().stream()
.map(this::mapToTagDto)
.toList());
}
// Map collection stories (lightweight)
if (collection.getCollectionStories() != null) {
dto.setCollectionStories(collection.getCollectionStories().stream()
.map(this::mapToCollectionStoryDto)
.toList());
}
// Set calculated properties
dto.setStoryCount(collection.getStoryCount());
dto.setTotalWordCount(collection.getTotalWordCount());
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
dto.setAverageStoryRating(collection.getAverageStoryRating());
return dto;
}
private CollectionStoryDto mapToCollectionStoryDto(CollectionStory collectionStory) {
CollectionStoryDto dto = new CollectionStoryDto();
dto.setPosition(collectionStory.getPosition());
dto.setAddedAt(collectionStory.getAddedAt());
dto.setStory(mapToStorySummaryDto(collectionStory.getStory()));
return dto;
}
private StorySummaryDto mapToStorySummaryDto(Story story) {
StorySummaryDto dto = new StorySummaryDto();
dto.setId(story.getId());
dto.setTitle(story.getTitle());
dto.setSummary(story.getSummary());
dto.setDescription(story.getDescription());
dto.setSourceUrl(story.getSourceUrl());
dto.setCoverPath(story.getCoverPath());
dto.setWordCount(story.getWordCount());
dto.setRating(story.getRating());
dto.setVolume(story.getVolume());
dto.setCreatedAt(story.getCreatedAt());
dto.setUpdatedAt(story.getUpdatedAt());
dto.setPartOfSeries(story.isPartOfSeries());
// Map author info
if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId());
dto.setAuthorName(story.getAuthor().getName());
}
// Map series info
if (story.getSeries() != null) {
dto.setSeriesId(story.getSeries().getId());
dto.setSeriesName(story.getSeries().getName());
}
// Map tags
if (story.getTags() != null) {
dto.setTags(story.getTags().stream()
.map(this::mapToTagDto)
.toList());
}
return dto;
}
private TagDto mapToTagDto(Tag tag) {
TagDto dto = new TagDto();
dto.setId(tag.getId());
dto.setName(tag.getName());
dto.setCreatedAt(tag.getCreatedAt());
return dto;
}
// Request DTOs
public static class CreateCollectionRequest {
private String name;
private String description;
private List<String> tagNames;
private List<UUID> storyIds;
// Getters and setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public List<String> getTagNames() { return tagNames; }
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
public List<UUID> getStoryIds() { return storyIds; }
public void setStoryIds(List<UUID> storyIds) { this.storyIds = storyIds; }
}
public static class UpdateCollectionRequest {
private String name;
private String description;
private List<String> tagNames;
private Integer rating;
// Getters and setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public List<String> getTagNames() { return tagNames; }
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
public Integer getRating() { return rating; }
public void setRating(Integer rating) { this.rating = rating; }
}
public static class ArchiveRequest {
private Boolean archived;
public Boolean getArchived() { return archived; }
public void setArchived(Boolean archived) { this.archived = archived; }
}
public static class AddStoriesRequest {
private List<UUID> storyIds;
private Integer position;
public List<UUID> getStoryIds() { return storyIds; }
public void setStoryIds(List<UUID> storyIds) { this.storyIds = storyIds; }
public Integer getPosition() { return position; }
public void setPosition(Integer position) { this.position = position; }
}
public static class ReorderStoriesRequest {
private List<Map<String, Object>> storyOrders;
public List<Map<String, Object>> getStoryOrders() { return storyOrders; }
public void setStoryOrders(List<Map<String, Object>> storyOrders) { this.storyOrders = storyOrders; }
}
}

View File

@@ -1,8 +1,8 @@
package com.storycove.controller; package com.storycove.controller;
import com.storycove.dto.StoryDto; import com.storycove.dto.*;
import com.storycove.dto.TagDto;
import com.storycove.entity.Author; import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.Series; import com.storycove.entity.Series;
import com.storycove.entity.Story; import com.storycove.entity.Story;
import com.storycove.entity.Tag; import com.storycove.entity.Tag;
@@ -23,10 +23,8 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -39,29 +37,29 @@ public class StoryController {
private final StoryService storyService; private final StoryService storyService;
private final AuthorService authorService; private final AuthorService authorService;
private final SeriesService seriesService; private final SeriesService seriesService;
private final TagService tagService;
private final HtmlSanitizationService sanitizationService; private final HtmlSanitizationService sanitizationService;
private final ImageService imageService; private final ImageService imageService;
private final TypesenseService typesenseService; private final TypesenseService typesenseService;
private final CollectionService collectionService;
public StoryController(StoryService storyService, public StoryController(StoryService storyService,
AuthorService authorService, AuthorService authorService,
SeriesService seriesService, SeriesService seriesService,
TagService tagService,
HtmlSanitizationService sanitizationService, HtmlSanitizationService sanitizationService,
ImageService imageService, ImageService imageService,
CollectionService collectionService,
@Autowired(required = false) TypesenseService typesenseService) { @Autowired(required = false) TypesenseService typesenseService) {
this.storyService = storyService; this.storyService = storyService;
this.authorService = authorService; this.authorService = authorService;
this.seriesService = seriesService; this.seriesService = seriesService;
this.tagService = tagService;
this.sanitizationService = sanitizationService; this.sanitizationService = sanitizationService;
this.imageService = imageService; this.imageService = imageService;
this.collectionService = collectionService;
this.typesenseService = typesenseService; this.typesenseService = typesenseService;
} }
@GetMapping @GetMapping
public ResponseEntity<Page<StoryDto>> getAllStories( public ResponseEntity<Page<StorySummaryDto>> getAllStories(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy, @RequestParam(defaultValue = "createdAt") String sortBy,
@@ -72,7 +70,7 @@ public class StoryController {
Pageable pageable = PageRequest.of(page, size, sort); Pageable pageable = PageRequest.of(page, size, sort);
Page<Story> stories = storyService.findAll(pageable); Page<Story> stories = storyService.findAll(pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto); Page<StorySummaryDto> storyDtos = stories.map(this::convertToSummaryDto);
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@@ -88,7 +86,7 @@ public class StoryController {
Story story = new Story(); Story story = new Story();
updateStoryFromRequest(story, request); updateStoryFromRequest(story, request);
Story savedStory = storyService.create(story); Story savedStory = storyService.createWithTagNames(story, request.getTagNames());
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedStory)); return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedStory));
} }
@@ -211,6 +209,8 @@ public class StoryController {
@RequestParam(required = false) String sortBy, @RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir) { @RequestParam(required = false) String sortDir) {
logger.info("CONTROLLER DEBUG: Search request - query='{}', tags={}, authors={}", query, tags, authors);
if (typesenseService != null) { if (typesenseService != null) {
SearchResultDto<StorySearchDto> results = typesenseService.searchStories( SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir); query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir);
@@ -235,57 +235,73 @@ public class StoryController {
} }
@GetMapping("/author/{authorId}") @GetMapping("/author/{authorId}")
public ResponseEntity<Page<StoryDto>> getStoriesByAuthor( public ResponseEntity<Page<StorySummaryDto>> getStoriesByAuthor(
@PathVariable UUID authorId, @PathVariable UUID authorId,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size); Pageable pageable = PageRequest.of(page, size);
Page<Story> stories = storyService.findByAuthor(authorId, pageable); Page<Story> stories = storyService.findByAuthor(authorId, pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto); Page<StorySummaryDto> storyDtos = stories.map(this::convertToSummaryDto);
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/series/{seriesId}") @GetMapping("/series/{seriesId}")
public ResponseEntity<List<StoryDto>> getStoriesBySeries(@PathVariable UUID seriesId) { public ResponseEntity<List<StorySummaryDto>> getStoriesBySeries(@PathVariable UUID seriesId) {
List<Story> stories = storyService.findBySeriesOrderByVolume(seriesId); List<Story> stories = storyService.findBySeriesOrderByVolume(seriesId);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); List<StorySummaryDto> storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/tags/{tagName}") @GetMapping("/tags/{tagName}")
public ResponseEntity<Page<StoryDto>> getStoriesByTag( public ResponseEntity<Page<StorySummaryDto>> getStoriesByTag(
@PathVariable String tagName, @PathVariable String tagName,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size); Pageable pageable = PageRequest.of(page, size);
Page<Story> stories = storyService.findByTagNames(List.of(tagName), pageable); Page<Story> stories = storyService.findByTagNames(List.of(tagName), pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto); Page<StorySummaryDto> storyDtos = stories.map(this::convertToSummaryDto);
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/recent") @GetMapping("/recent")
public ResponseEntity<List<StoryDto>> getRecentStories(@RequestParam(defaultValue = "10") int limit) { public ResponseEntity<List<StorySummaryDto>> getRecentStories(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit, Sort.by("createdAt").descending()); Pageable pageable = PageRequest.of(0, limit, Sort.by("createdAt").descending());
List<Story> stories = storyService.findRecentlyAddedLimited(pageable); List<Story> stories = storyService.findRecentlyAddedLimited(pageable);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); List<StorySummaryDto> storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/top-rated") @GetMapping("/top-rated")
public ResponseEntity<List<StoryDto>> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) { public ResponseEntity<List<StorySummaryDto>> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit); Pageable pageable = PageRequest.of(0, limit);
List<Story> stories = storyService.findTopRatedStoriesLimited(pageable); List<Story> stories = storyService.findTopRatedStoriesLimited(pageable);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); List<StorySummaryDto> storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/{id}/collections")
public ResponseEntity<List<CollectionDto>> getStoryCollections(@PathVariable UUID id) {
List<Collection> collections = collectionService.getCollectionsForStory(id);
List<CollectionDto> collectionDtos = collections.stream()
.map(this::convertToCollectionDto)
.collect(Collectors.toList());
return ResponseEntity.ok(collectionDtos);
}
@PostMapping("/batch/add-to-collection")
public ResponseEntity<Map<String, Object>> addStoriesToCollection(@RequestBody BatchAddToCollectionRequest request) {
// This endpoint will be implemented once we have the complete collection service
return ResponseEntity.ok(Map.of("message", "Batch add to collection endpoint - to be implemented"));
}
private Author findOrCreateAuthor(String authorName) { private Author findOrCreateAuthor(String authorName) {
// First try to find existing author by name // First try to find existing author by name
try { try {
@@ -325,20 +341,7 @@ public class StoryController {
story.setSeries(series); story.setSeries(series);
} }
// Handle tags // Tags are handled in the service layer
if (createReq.getTagNames() != null && !createReq.getTagNames().isEmpty()) {
for (String tagName : createReq.getTagNames()) {
if (tagName != null && !tagName.trim().isEmpty()) {
Tag tag = tagService.findByNameOptional(tagName.trim().toLowerCase())
.orElseGet(() -> {
Tag newTag = new Tag();
newTag.setName(tagName.trim().toLowerCase());
return tagService.create(newTag);
});
story.addTag(tag);
}
}
}
} else if (request instanceof UpdateStoryRequest updateReq) { } else if (request instanceof UpdateStoryRequest updateReq) {
if (updateReq.getTitle() != null) { if (updateReq.getTitle() != null) {
story.setTitle(updateReq.getTitle()); story.setTitle(updateReq.getTitle());
@@ -408,6 +411,38 @@ public class StoryController {
return dto; return dto;
} }
private StorySummaryDto convertToSummaryDto(Story story) {
StorySummaryDto dto = new StorySummaryDto();
dto.setId(story.getId());
dto.setTitle(story.getTitle());
dto.setSummary(story.getSummary());
dto.setDescription(story.getDescription());
dto.setSourceUrl(story.getSourceUrl());
dto.setCoverPath(story.getCoverPath());
dto.setWordCount(story.getWordCount());
dto.setRating(story.getRating());
dto.setVolume(story.getVolume());
dto.setCreatedAt(story.getCreatedAt());
dto.setUpdatedAt(story.getUpdatedAt());
dto.setPartOfSeries(story.isPartOfSeries());
if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId());
dto.setAuthorName(story.getAuthor().getName());
}
if (story.getSeries() != null) {
dto.setSeriesId(story.getSeries().getId());
dto.setSeriesName(story.getSeries().getName());
}
dto.setTags(story.getTags().stream()
.map(this::convertTagToDto)
.collect(Collectors.toList()));
return dto;
}
private TagDto convertTagToDto(Tag tag) { private TagDto convertTagToDto(Tag tag) {
TagDto tagDto = new TagDto(); TagDto tagDto = new TagDto();
tagDto.setId(tag.getId()); tagDto.setId(tag.getId());
@@ -417,6 +452,27 @@ public class StoryController {
return tagDto; return tagDto;
} }
private CollectionDto convertToCollectionDto(Collection collection) {
CollectionDto dto = new CollectionDto();
dto.setId(collection.getId());
dto.setName(collection.getName());
dto.setDescription(collection.getDescription());
dto.setRating(collection.getRating());
dto.setCoverImagePath(collection.getCoverImagePath());
dto.setIsArchived(collection.getIsArchived());
dto.setCreatedAt(collection.getCreatedAt());
dto.setUpdatedAt(collection.getUpdatedAt());
// For story collections endpoint, we don't need to map the stories themselves
// to avoid circular references and keep it lightweight
dto.setStoryCount(collection.getStoryCount());
dto.setTotalWordCount(collection.getTotalWordCount());
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
dto.setAverageStoryRating(collection.getAverageStoryRating());
return dto;
}
// Request DTOs // Request DTOs
public static class CreateStoryRequest { public static class CreateStoryRequest {
private String title; private String title;
@@ -497,4 +553,17 @@ public class StoryController {
public Integer getRating() { return rating; } public Integer getRating() { return rating; }
public void setRating(Integer rating) { this.rating = rating; } public void setRating(Integer rating) { this.rating = rating; }
} }
public static class BatchAddToCollectionRequest {
private List<UUID> storyIds;
private UUID collectionId;
private String newCollectionName;
public List<UUID> getStoryIds() { return storyIds; }
public void setStoryIds(List<UUID> storyIds) { this.storyIds = storyIds; }
public UUID getCollectionId() { return collectionId; }
public void setCollectionId(UUID collectionId) { this.collectionId = collectionId; }
public String getNewCollectionName() { return newCollectionName; }
public void setNewCollectionName(String newCollectionName) { this.newCollectionName = newCollectionName; }
}
} }

View File

@@ -0,0 +1,106 @@
package com.storycove.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Lightweight Author DTO for listings.
* Excludes story collections to reduce payload size.
*/
public class AuthorSummaryDto {
private UUID id;
private String name;
private String notes;
private String avatarImagePath;
private Integer authorRating;
private Double averageStoryRating;
private Integer storyCount;
private List<String> urls;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public AuthorSummaryDto() {}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public String getAvatarImagePath() {
return avatarImagePath;
}
public void setAvatarImagePath(String avatarImagePath) {
this.avatarImagePath = avatarImagePath;
}
public Integer getAuthorRating() {
return authorRating;
}
public void setAuthorRating(Integer authorRating) {
this.authorRating = authorRating;
}
public Double getAverageStoryRating() {
return averageStoryRating;
}
public void setAverageStoryRating(Double averageStoryRating) {
this.averageStoryRating = averageStoryRating;
}
public Integer getStoryCount() {
return storyCount;
}
public void setStoryCount(Integer storyCount) {
this.storyCount = storyCount;
}
public List<String> getUrls() {
return urls;
}
public void setUrls(List<String> urls) {
this.urls = urls;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,141 @@
package com.storycove.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* DTO for Collection with lightweight story references
*/
public class CollectionDto {
private UUID id;
private String name;
private String description;
private Integer rating;
private String coverImagePath;
private Boolean isArchived;
private List<TagDto> tags;
private List<CollectionStoryDto> collectionStories;
private Integer storyCount;
private Integer totalWordCount;
private Integer estimatedReadingTime;
private Double averageStoryRating;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public CollectionDto() {}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getRating() {
return rating;
}
public void setRating(Integer rating) {
this.rating = rating;
}
public String getCoverImagePath() {
return coverImagePath;
}
public void setCoverImagePath(String coverImagePath) {
this.coverImagePath = coverImagePath;
}
public Boolean getIsArchived() {
return isArchived;
}
public void setIsArchived(Boolean isArchived) {
this.isArchived = isArchived;
}
public List<TagDto> getTags() {
return tags;
}
public void setTags(List<TagDto> tags) {
this.tags = tags;
}
public List<CollectionStoryDto> getCollectionStories() {
return collectionStories;
}
public void setCollectionStories(List<CollectionStoryDto> collectionStories) {
this.collectionStories = collectionStories;
}
public Integer getStoryCount() {
return storyCount;
}
public void setStoryCount(Integer storyCount) {
this.storyCount = storyCount;
}
public Integer getTotalWordCount() {
return totalWordCount;
}
public void setTotalWordCount(Integer totalWordCount) {
this.totalWordCount = totalWordCount;
}
public Integer getEstimatedReadingTime() {
return estimatedReadingTime;
}
public void setEstimatedReadingTime(Integer estimatedReadingTime) {
this.estimatedReadingTime = estimatedReadingTime;
}
public Double getAverageStoryRating() {
return averageStoryRating;
}
public void setAverageStoryRating(Double averageStoryRating) {
this.averageStoryRating = averageStoryRating;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,46 @@
package com.storycove.dto;
import java.time.LocalDateTime;
/**
* DTO for CollectionStory with lightweight story reference
*/
public class CollectionStoryDto {
private StorySummaryDto story;
private Integer position;
private LocalDateTime addedAt;
public CollectionStoryDto() {}
public CollectionStoryDto(StorySummaryDto story, Integer position, LocalDateTime addedAt) {
this.story = story;
this.position = position;
this.addedAt = addedAt;
}
// Getters and Setters
public StorySummaryDto getStory() {
return story;
}
public void setStory(StorySummaryDto story) {
this.story = story;
}
public Integer getPosition() {
return position;
}
public void setPosition(Integer position) {
this.position = position;
}
public LocalDateTime getAddedAt() {
return addedAt;
}
public void setAddedAt(LocalDateTime addedAt) {
this.addedAt = addedAt;
}
}

View File

@@ -0,0 +1,172 @@
package com.storycove.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Lightweight Story DTO for listings and collection views.
* Excludes contentHtml and contentPlain to reduce payload size.
*/
public class StorySummaryDto {
private UUID id;
private String title;
private String summary;
private String description;
private String sourceUrl;
private String coverPath;
private Integer wordCount;
private Integer rating;
private Integer volume;
// Related entities as simple references
private UUID authorId;
private String authorName;
private UUID seriesId;
private String seriesName;
private List<TagDto> tags;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private boolean partOfSeries;
public StorySummaryDto() {}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getSourceUrl() {
return sourceUrl;
}
public void setSourceUrl(String sourceUrl) {
this.sourceUrl = sourceUrl;
}
public String getCoverPath() {
return coverPath;
}
public void setCoverPath(String coverPath) {
this.coverPath = coverPath;
}
public Integer getWordCount() {
return wordCount;
}
public void setWordCount(Integer wordCount) {
this.wordCount = wordCount;
}
public Integer getRating() {
return rating;
}
public void setRating(Integer rating) {
this.rating = rating;
}
public Integer getVolume() {
return volume;
}
public void setVolume(Integer volume) {
this.volume = volume;
}
public UUID getAuthorId() {
return authorId;
}
public void setAuthorId(UUID authorId) {
this.authorId = authorId;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public UUID getSeriesId() {
return seriesId;
}
public void setSeriesId(UUID seriesId) {
this.seriesId = seriesId;
}
public String getSeriesName() {
return seriesName;
}
public void setSeriesName(String seriesName) {
this.seriesName = seriesName;
}
public List<TagDto> getTags() {
return tags;
}
public void setTags(List<TagDto> tags) {
this.tags = tags;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public boolean isPartOfSeries() {
return partOfSeries;
}
public void setPartOfSeries(boolean partOfSeries) {
this.partOfSeries = partOfSeries;
}
}

View File

@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -40,6 +41,7 @@ public class Author {
private List<String> urls = new ArrayList<>(); private List<String> urls = new ArrayList<>();
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JsonManagedReference("author-stories")
private List<Story> stories = new ArrayList<>(); private List<Story> stories = new ArrayList<>();
@CreationTimestamp @CreationTimestamp

View File

@@ -0,0 +1,233 @@
package com.storycove.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "collections")
public class Collection {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotBlank(message = "Collection name is required")
@Size(max = 500, message = "Collection name must not exceed 500 characters")
@Column(nullable = false, length = 500)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "rating")
private Integer rating;
@Column(name = "cover_image_path", length = 500)
private String coverImagePath;
@Column(name = "is_archived", nullable = false)
private Boolean isArchived = false;
@OneToMany(mappedBy = "collection", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC")
@JsonManagedReference("collection-stories")
private List<CollectionStory> collectionStories = new ArrayList<>();
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "collection_tags",
joinColumns = @JoinColumn(name = "collection_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Collection() {}
public Collection(String name) {
this.name = name;
}
public Collection(String name, String description) {
this.name = name;
this.description = description;
}
// Helper methods for managing collection stories
public void addStory(Story story, int position) {
CollectionStory collectionStory = new CollectionStory();
collectionStory.setCollection(this);
collectionStory.setStory(story);
collectionStory.setPosition(position);
collectionStories.add(collectionStory);
}
public void removeStory(UUID storyId) {
collectionStories.removeIf(cs -> cs.getStory().getId().equals(storyId));
}
public void reorderStories(List<UUID> storyIds) {
for (int i = 0; i < storyIds.size(); i++) {
UUID storyId = storyIds.get(i);
final int position = (i + 1) * 1000; // Gap-based positioning
collectionStories.stream()
.filter(cs -> cs.getStory().getId().equals(storyId))
.findFirst()
.ifPresent(cs -> cs.setPosition(position));
}
}
public void addTag(Tag tag) {
tags.add(tag);
}
public void removeTag(Tag tag) {
tags.remove(tag);
}
// Calculated properties
public int getStoryCount() {
return collectionStories.size();
}
public int getTotalWordCount() {
return collectionStories.stream()
.mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0)
.sum();
}
public int getEstimatedReadingTime() {
// Assuming 200 words per minute reading speed
return Math.max(1, getTotalWordCount() / 200);
}
public Double getAverageStoryRating() {
return collectionStories.stream()
.filter(cs -> cs.getStory().getRating() != null)
.mapToInt(cs -> cs.getStory().getRating())
.average()
.orElse(0.0);
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getRating() {
return rating;
}
public void setRating(Integer rating) {
this.rating = rating;
}
public String getCoverImagePath() {
return coverImagePath;
}
public void setCoverImagePath(String coverImagePath) {
this.coverImagePath = coverImagePath;
}
public Boolean getIsArchived() {
return isArchived;
}
public void setIsArchived(Boolean isArchived) {
this.isArchived = isArchived;
}
public List<CollectionStory> getCollectionStories() {
return collectionStories;
}
public void setCollectionStories(List<CollectionStory> collectionStories) {
this.collectionStories = collectionStories;
}
public Set<Tag> getTags() {
return tags;
}
public void setTags(Set<Tag> tags) {
this.tags = tags;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Collection)) return false;
Collection collection = (Collection) o;
return id != null && id.equals(collection.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Collection{" +
"id=" + id +
", name='" + name + '\'' +
", storyCount=" + getStoryCount() +
", isArchived=" + isArchived +
'}';
}
}

View File

@@ -0,0 +1,114 @@
package com.storycove.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import com.fasterxml.jackson.annotation.JsonBackReference;
import java.time.LocalDateTime;
@Entity
@Table(name = "collection_stories",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"collection_id", "position"})
})
public class CollectionStory {
@EmbeddedId
private CollectionStoryId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("collectionId")
@JoinColumn(name = "collection_id")
@JsonBackReference("collection-stories")
private Collection collection;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("storyId")
@JoinColumn(name = "story_id")
private Story story;
@Column(nullable = false)
private Integer position;
@CreationTimestamp
@Column(name = "added_at", nullable = false, updatable = false)
private LocalDateTime addedAt;
public CollectionStory() {}
public CollectionStory(Collection collection, Story story, Integer position) {
this.id = new CollectionStoryId(collection.getId(), story.getId());
this.collection = collection;
this.story = story;
this.position = position;
}
// Getters and Setters
public CollectionStoryId getId() {
return id;
}
public void setId(CollectionStoryId id) {
this.id = id;
}
public Collection getCollection() {
return collection;
}
public void setCollection(Collection collection) {
this.collection = collection;
if (this.story != null) {
this.id = new CollectionStoryId(collection.getId(), this.story.getId());
}
}
public Story getStory() {
return story;
}
public void setStory(Story story) {
this.story = story;
if (this.collection != null) {
this.id = new CollectionStoryId(this.collection.getId(), story.getId());
}
}
public Integer getPosition() {
return position;
}
public void setPosition(Integer position) {
this.position = position;
}
public LocalDateTime getAddedAt() {
return addedAt;
}
public void setAddedAt(LocalDateTime addedAt) {
this.addedAt = addedAt;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CollectionStory)) return false;
CollectionStory that = (CollectionStory) o;
return id != null && id.equals(that.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "CollectionStory{" +
"collectionId=" + (collection != null ? collection.getId() : null) +
", storyId=" + (story != null ? story.getId() : null) +
", position=" + position +
'}';
}
}

View File

@@ -0,0 +1,61 @@
package com.storycove.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.util.UUID;
@Embeddable
public class CollectionStoryId implements java.io.Serializable {
@Column(name = "collection_id")
private UUID collectionId;
@Column(name = "story_id")
private UUID storyId;
public CollectionStoryId() {}
public CollectionStoryId(UUID collectionId, UUID storyId) {
this.collectionId = collectionId;
this.storyId = storyId;
}
// Getters and Setters
public UUID getCollectionId() {
return collectionId;
}
public void setCollectionId(UUID collectionId) {
this.collectionId = collectionId;
}
public UUID getStoryId() {
return storyId;
}
public void setStoryId(UUID storyId) {
this.storyId = storyId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CollectionStoryId)) return false;
CollectionStoryId that = (CollectionStoryId) o;
return collectionId != null && collectionId.equals(that.collectionId) &&
storyId != null && storyId.equals(that.storyId);
}
@Override
public int hashCode() {
return java.util.Objects.hash(collectionId, storyId);
}
@Override
public String toString() {
return "CollectionStoryId{" +
"collectionId=" + collectionId +
", storyId=" + storyId +
'}';
}
}

View File

@@ -4,6 +4,7 @@ import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -29,6 +30,7 @@ public class Series {
@OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@OrderBy("volume ASC") @OrderBy("volume ASC")
@JsonManagedReference("series-stories")
private List<Story> stories = new ArrayList<>(); private List<Story> stories = new ArrayList<>();
@CreationTimestamp @CreationTimestamp

View File

@@ -6,6 +6,8 @@ import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import com.fasterxml.jackson.annotation.JsonBackReference;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet; import java.util.HashSet;
@@ -55,10 +57,12 @@ public class Story {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id") @JoinColumn(name = "author_id")
@JsonBackReference("author-stories")
private Author author; private Author author;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "series_id") @JoinColumn(name = "series_id")
@JsonBackReference("series-stories")
private Series series; private Series series;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@@ -67,6 +71,7 @@ public class Story {
joinColumns = @JoinColumn(name = "story_id"), joinColumns = @JoinColumn(name = "story_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id") inverseJoinColumns = @JoinColumn(name = "tag_id")
) )
@JsonManagedReference("story-tags")
private Set<Tag> tags = new HashSet<>(); private Set<Tag> tags = new HashSet<>();
@CreationTimestamp @CreationTimestamp

View File

@@ -4,6 +4,7 @@ import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import com.fasterxml.jackson.annotation.JsonBackReference;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet; import java.util.HashSet;
@@ -25,6 +26,7 @@ public class Tag {
@ManyToMany(mappedBy = "tags") @ManyToMany(mappedBy = "tags")
@JsonBackReference("story-tags")
private Set<Story> stories = new HashSet<>(); private Set<Story> stories = new HashSet<>();
@CreationTimestamp @CreationTimestamp

View File

@@ -0,0 +1,48 @@
package com.storycove.repository;
import com.storycove.entity.Collection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface CollectionRepository extends JpaRepository<Collection, UUID> {
/**
* Find collection by ID with tags eagerly loaded
* Used for detailed collection retrieval
*/
@Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags WHERE c.id = :id")
Optional<Collection> findByIdWithTags(@Param("id") UUID id);
/**
* Find collection by ID with full story details
* Used for collection detail view with story list
*/
@Query("SELECT c FROM Collection c " +
"LEFT JOIN FETCH c.collectionStories cs " +
"LEFT JOIN FETCH cs.story s " +
"LEFT JOIN FETCH s.author " +
"LEFT JOIN FETCH c.tags " +
"WHERE c.id = :id " +
"ORDER BY cs.position ASC")
Optional<Collection> findByIdWithStoriesAndTags(@Param("id") UUID id);
/**
* Count all collections for statistics
*/
long countByIsArchivedFalse();
/**
* Find all collections with basic info (for batch operations)
* NOTE: This method should only be used for operations that require all collections
* For search/filter/list operations, use TypesenseService instead
*/
@Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC")
List<Collection> findAllActiveCollections();
}

View File

@@ -0,0 +1,93 @@
package com.storycove.repository;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.CollectionStoryId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface CollectionStoryRepository extends JpaRepository<CollectionStory, CollectionStoryId> {
/**
* Find all stories in a collection ordered by position
*/
@Query("SELECT cs FROM CollectionStory cs " +
"LEFT JOIN FETCH cs.story s " +
"LEFT JOIN FETCH s.author " +
"WHERE cs.collection.id = :collectionId " +
"ORDER BY cs.position ASC")
List<CollectionStory> findByCollectionIdOrderByPosition(@Param("collectionId") UUID collectionId);
/**
* Find story by collection and story ID
*/
@Query("SELECT cs FROM CollectionStory cs " +
"WHERE cs.collection.id = :collectionId AND cs.story.id = :storyId")
CollectionStory findByCollectionIdAndStoryId(@Param("collectionId") UUID collectionId, @Param("storyId") UUID storyId);
/**
* Get next available position in collection
*/
@Query("SELECT COALESCE(MAX(cs.position), 0) + 1000 FROM CollectionStory cs WHERE cs.collection.id = :collectionId")
Integer getNextPosition(@Param("collectionId") UUID collectionId);
/**
* Remove all stories from a collection (used when deleting collection)
*/
@Modifying
@Query("DELETE FROM CollectionStory cs WHERE cs.collection.id = :collectionId")
void deleteByCollectionId(@Param("collectionId") UUID collectionId);
/**
* Update positions for stories in a collection
* Used for bulk position updates during reordering
*/
@Modifying
@Query("UPDATE CollectionStory cs SET cs.position = :position " +
"WHERE cs.collection.id = :collectionId AND cs.story.id = :storyId")
void updatePosition(@Param("collectionId") UUID collectionId,
@Param("storyId") UUID storyId,
@Param("position") Integer position);
/**
* Check if a story already exists in a collection
*/
boolean existsByCollectionIdAndStoryId(UUID collectionId, UUID storyId);
/**
* Count stories in a collection
*/
long countByCollectionId(UUID collectionId);
/**
* Find all collections that contain a specific story
*/
@Query("SELECT cs FROM CollectionStory cs " +
"LEFT JOIN FETCH cs.collection c " +
"WHERE cs.story.id = :storyId " +
"ORDER BY c.name ASC")
List<CollectionStory> findByStoryId(@Param("storyId") UUID storyId);
/**
* Find previous and next stories for reading navigation
*/
@Query("SELECT cs FROM CollectionStory cs " +
"WHERE cs.collection.id = :collectionId " +
"AND cs.position < (SELECT current.position FROM CollectionStory current " +
" WHERE current.collection.id = :collectionId AND current.story.id = :currentStoryId) " +
"ORDER BY cs.position DESC")
List<CollectionStory> findPreviousStory(@Param("collectionId") UUID collectionId, @Param("currentStoryId") UUID currentStoryId);
@Query("SELECT cs FROM CollectionStory cs " +
"WHERE cs.collection.id = :collectionId " +
"AND cs.position > (SELECT current.position FROM CollectionStory current " +
" WHERE current.collection.id = :collectionId AND current.story.id = :currentStoryId) " +
"ORDER BY cs.position ASC")
List<CollectionStory> findNextStory(@Param("collectionId") UUID collectionId, @Param("currentStoryId") UUID currentStoryId);
}

View File

@@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -338,9 +339,11 @@ public class AuthorService {
if (updates.getAuthorRating() != null) { if (updates.getAuthorRating() != null) {
existing.setAuthorRating(updates.getAuthorRating()); existing.setAuthorRating(updates.getAuthorRating());
} }
if (updates.getUrls() != null && !updates.getUrls().isEmpty()) { if (updates.getUrls() != null) {
// Create a defensive copy to avoid issues when existing and updates are the same object
List<String> urlsCopy = new ArrayList<>(updates.getUrls());
existing.getUrls().clear(); existing.getUrls().clear();
existing.getUrls().addAll(updates.getUrls()); existing.getUrls().addAll(urlsCopy);
} }
} }
} }

View File

@@ -0,0 +1,56 @@
package com.storycove.service;
import com.storycove.entity.Collection;
/**
* Special Collection subclass for search results that provides pre-calculated statistics
* to avoid lazy loading issues when displaying collection lists.
*/
public class CollectionSearchResult extends Collection {
private Integer storedStoryCount;
private Integer storedTotalWordCount;
public CollectionSearchResult(Collection collection) {
this.setId(collection.getId());
this.setName(collection.getName());
this.setDescription(collection.getDescription());
this.setRating(collection.getRating());
this.setIsArchived(collection.getIsArchived());
this.setCreatedAt(collection.getCreatedAt());
this.setUpdatedAt(collection.getUpdatedAt());
this.setCoverImagePath(collection.getCoverImagePath());
// Note: don't copy collectionStories or tags to avoid lazy loading issues
}
public void setStoredStoryCount(Integer storyCount) {
this.storedStoryCount = storyCount;
}
public void setStoredTotalWordCount(Integer totalWordCount) {
this.storedTotalWordCount = totalWordCount;
}
@Override
public int getStoryCount() {
return storedStoryCount != null ? storedStoryCount : 0;
}
@Override
public int getTotalWordCount() {
return storedTotalWordCount != null ? storedTotalWordCount : 0;
}
@Override
public int getEstimatedReadingTime() {
// Assuming 200 words per minute reading speed
return Math.max(1, getTotalWordCount() / 200);
}
@Override
public Double getAverageStoryRating() {
// For search results, we don't calculate average rating to avoid complexity
// This would require loading all stories. Can be enhanced later if needed.
return null;
}
}

View File

@@ -0,0 +1,423 @@
package com.storycove.service;
import com.storycove.dto.SearchResultDto;
import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.repository.CollectionRepository;
import com.storycove.repository.CollectionStoryRepository;
import com.storycove.repository.StoryRepository;
import com.storycove.repository.TagRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Transactional
public class CollectionService {
private static final Logger logger = LoggerFactory.getLogger(CollectionService.class);
private final CollectionRepository collectionRepository;
private final CollectionStoryRepository collectionStoryRepository;
private final StoryRepository storyRepository;
private final TagRepository tagRepository;
private final TypesenseService typesenseService;
@Autowired
public CollectionService(CollectionRepository collectionRepository,
CollectionStoryRepository collectionStoryRepository,
StoryRepository storyRepository,
TagRepository tagRepository,
@Autowired(required = false) TypesenseService typesenseService) {
this.collectionRepository = collectionRepository;
this.collectionStoryRepository = collectionStoryRepository;
this.storyRepository = storyRepository;
this.tagRepository = tagRepository;
this.typesenseService = typesenseService;
}
/**
* Search collections using Typesense (MANDATORY for all search/filter operations)
* This method MUST be used instead of JPA queries for listing collections
*/
public SearchResultDto<Collection> searchCollections(String query, List<String> tags, boolean includeArchived, int page, int limit) {
if (typesenseService == null) {
logger.warn("Typesense service not available, returning empty results");
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
}
// Delegate to TypesenseService for all search operations
return typesenseService.searchCollections(query, tags, includeArchived, page, limit);
}
/**
* Find collection by ID with full details
*/
public Collection findById(UUID id) {
return collectionRepository.findByIdWithStoriesAndTags(id)
.orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id));
}
/**
* Find collection by ID with basic info only
*/
public Collection findByIdBasic(UUID id) {
return collectionRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id));
}
/**
* Create a new collection with optional initial stories
*/
public Collection createCollection(String name, String description, List<String> tagNames, List<UUID> initialStoryIds) {
Collection collection = new Collection(name, description);
// Add tags if provided
if (tagNames != null && !tagNames.isEmpty()) {
Set<Tag> tags = findOrCreateTags(tagNames);
collection.setTags(tags);
}
Collection savedCollection = collectionRepository.save(collection);
// Add initial stories if provided
if (initialStoryIds != null && !initialStoryIds.isEmpty()) {
addStoriesToCollection(savedCollection.getId(), initialStoryIds, null);
// Reload to get updated collection with stories
savedCollection = findById(savedCollection.getId());
}
// Index in Typesense
if (typesenseService != null) {
typesenseService.indexCollection(savedCollection);
}
logger.info("Created collection: {} with {} stories", name, initialStoryIds != null ? initialStoryIds.size() : 0);
return savedCollection;
}
/**
* Update collection metadata
*/
public Collection updateCollection(UUID id, String name, String description, List<String> tagNames, Integer rating) {
Collection collection = findByIdBasic(id);
if (name != null) {
collection.setName(name);
}
if (description != null) {
collection.setDescription(description);
}
if (rating != null) {
collection.setRating(rating);
}
// Update tags if provided
if (tagNames != null) {
Set<Tag> tags = findOrCreateTags(tagNames);
collection.setTags(tags);
}
Collection savedCollection = collectionRepository.save(collection);
// Update in Typesense
if (typesenseService != null) {
typesenseService.indexCollection(savedCollection);
}
logger.info("Updated collection: {}", id);
return savedCollection;
}
/**
* Delete a collection (stories remain in the system)
*/
public void deleteCollection(UUID id) {
Collection collection = findByIdBasic(id);
// Remove from Typesense first
if (typesenseService != null) {
typesenseService.removeCollection(id);
}
collectionRepository.delete(collection);
logger.info("Deleted collection: {}", id);
}
/**
* Archive or unarchive a collection
*/
public Collection archiveCollection(UUID id, boolean archived) {
Collection collection = findByIdBasic(id);
collection.setIsArchived(archived);
Collection savedCollection = collectionRepository.save(collection);
// Update in Typesense
if (typesenseService != null) {
typesenseService.indexCollection(savedCollection);
}
logger.info("{} collection: {}", archived ? "Archived" : "Unarchived", id);
return savedCollection;
}
/**
* Add stories to a collection
*/
public Map<String, Object> addStoriesToCollection(UUID collectionId, List<UUID> storyIds, Integer startPosition) {
Collection collection = findByIdBasic(collectionId);
// Validate stories exist
List<Story> stories = storyRepository.findAllById(storyIds);
if (stories.size() != storyIds.size()) {
throw new ResourceNotFoundException("One or more stories not found");
}
int added = 0;
int skipped = 0;
// Get starting position
int position = startPosition != null ? startPosition : collectionStoryRepository.getNextPosition(collectionId);
for (UUID storyId : storyIds) {
// Check if story is already in collection
if (collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId)) {
skipped++;
continue;
}
// Add story to collection
Story story = stories.stream()
.filter(s -> s.getId().equals(storyId))
.findFirst()
.orElseThrow();
CollectionStory collectionStory = new CollectionStory(collection, story, position);
collectionStoryRepository.save(collectionStory);
added++;
position += 1000; // Gap-based positioning
}
// Update collection in Typesense
if (typesenseService != null) {
Collection updatedCollection = findById(collectionId);
typesenseService.indexCollection(updatedCollection);
}
long totalStories = collectionStoryRepository.countByCollectionId(collectionId);
logger.info("Added {} stories to collection {}, skipped {} duplicates", added, collectionId, skipped);
return Map.of(
"added", added,
"skipped", skipped,
"totalStories", totalStories
);
}
/**
* Remove a story from a collection
*/
public void removeStoryFromCollection(UUID collectionId, UUID storyId) {
if (!collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId)) {
throw new ResourceNotFoundException("Story not found in collection");
}
CollectionStory collectionStory = collectionStoryRepository.findByCollectionIdAndStoryId(collectionId, storyId);
collectionStoryRepository.delete(collectionStory);
// Update collection in Typesense
if (typesenseService != null) {
Collection updatedCollection = findById(collectionId);
typesenseService.indexCollection(updatedCollection);
}
logger.info("Removed story {} from collection {}", storyId, collectionId);
}
/**
* Reorder stories in a collection
*/
@Transactional
public void reorderStories(UUID collectionId, List<Map<String, Object>> storyOrders) {
Collection collection = findByIdBasic(collectionId);
// Two-phase update to avoid unique constraint violations:
// Phase 1: Set all positions to negative values (temporary)
logger.debug("Phase 1: Setting temporary negative positions for collection {}", collectionId);
for (int i = 0; i < storyOrders.size(); i++) {
Map<String, Object> order = storyOrders.get(i);
UUID storyId = UUID.fromString(String.valueOf(order.get("storyId")));
// Set temporary negative position to avoid conflicts
collectionStoryRepository.updatePosition(collectionId, storyId, -(i + 1));
}
// Phase 2: Set final positions
logger.debug("Phase 2: Setting final positions for collection {}", collectionId);
for (Map<String, Object> order : storyOrders) {
UUID storyId = UUID.fromString(String.valueOf(order.get("storyId")));
Integer position = (Integer) order.get("position");
collectionStoryRepository.updatePosition(collectionId, storyId, position * 1000); // Gap-based positioning
}
// Update collection in Typesense
if (typesenseService != null) {
Collection updatedCollection = findById(collectionId);
typesenseService.indexCollection(updatedCollection);
}
logger.info("Reordered {} stories in collection {}", storyOrders.size(), collectionId);
}
/**
* Get story with collection reading context
*/
public Map<String, Object> getStoryWithCollectionContext(UUID collectionId, UUID storyId) {
Collection collection = findByIdBasic(collectionId);
Story story = storyRepository.findById(storyId)
.orElseThrow(() -> new ResourceNotFoundException("Story not found: " + storyId));
// Find current position
CollectionStory currentStory = collectionStoryRepository.findByCollectionIdAndStoryId(collectionId, storyId);
if (currentStory == null) {
throw new ResourceNotFoundException("Story not found in collection");
}
// Find previous and next stories
List<CollectionStory> previousStories = collectionStoryRepository.findPreviousStory(collectionId, storyId);
List<CollectionStory> nextStories = collectionStoryRepository.findNextStory(collectionId, storyId);
UUID previousStoryId = previousStories.isEmpty() ? null : previousStories.get(0).getStory().getId();
UUID nextStoryId = nextStories.isEmpty() ? null : nextStories.get(0).getStory().getId();
// Get current position in collection
List<CollectionStory> allStories = collectionStoryRepository.findByCollectionIdOrderByPosition(collectionId);
int currentPosition = 0;
for (int i = 0; i < allStories.size(); i++) {
if (allStories.get(i).getStory().getId().equals(storyId)) {
currentPosition = i + 1;
break;
}
}
Map<String, Object> collectionContext = Map.of(
"id", collection.getId(),
"name", collection.getName(),
"currentPosition", currentPosition,
"totalStories", allStories.size(),
"previousStoryId", previousStoryId != null ? previousStoryId : "",
"nextStoryId", nextStoryId != null ? nextStoryId : ""
);
return Map.of(
"story", story,
"collection", collectionContext
);
}
/**
* Get collection statistics
*/
public Map<String, Object> getCollectionStatistics(UUID collectionId) {
Collection collection = findById(collectionId);
List<CollectionStory> collectionStories = collection.getCollectionStories();
// Calculate statistics
int totalStories = collectionStories.size();
int totalWordCount = collectionStories.stream()
.mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0)
.sum();
int estimatedReadingTime = Math.max(1, totalWordCount / 200); // 200 words per minute
double averageStoryRating = collectionStories.stream()
.filter(cs -> cs.getStory().getRating() != null)
.mapToInt(cs -> cs.getStory().getRating())
.average()
.orElse(0.0);
double averageWordCount = totalStories > 0 ? (double) totalWordCount / totalStories : 0.0;
// Tag frequency
Map<String, Long> tagFrequency = collectionStories.stream()
.flatMap(cs -> cs.getStory().getTags().stream())
.collect(Collectors.groupingBy(Tag::getName, Collectors.counting()));
// Author distribution
List<Map<String, Object>> authorDistribution = collectionStories.stream()
.filter(cs -> cs.getStory().getAuthor() != null)
.collect(Collectors.groupingBy(cs -> cs.getStory().getAuthor().getName(), Collectors.counting()))
.entrySet().stream()
.map(entry -> Map.<String, Object>of(
"authorName", entry.getKey(),
"storyCount", entry.getValue()
))
.sorted((a, b) -> Long.compare((Long) b.get("storyCount"), (Long) a.get("storyCount")))
.collect(Collectors.toList());
return Map.of(
"totalStories", totalStories,
"totalWordCount", totalWordCount,
"estimatedReadingTime", estimatedReadingTime,
"averageStoryRating", Math.round(averageStoryRating * 100.0) / 100.0,
"averageWordCount", Math.round(averageWordCount),
"tagFrequency", tagFrequency,
"authorDistribution", authorDistribution
);
}
/**
* Find or create tags by names
*/
private Set<Tag> findOrCreateTags(List<String> tagNames) {
Set<Tag> tags = new HashSet<>();
for (String tagName : tagNames) {
String trimmedName = tagName.trim();
if (!trimmedName.isEmpty()) {
Tag tag = tagRepository.findByName(trimmedName)
.orElseGet(() -> {
Tag newTag = new Tag();
newTag.setName(trimmedName);
return tagRepository.save(newTag);
});
tags.add(tag);
}
}
return tags;
}
/**
* Get collections that contain a specific story
*/
public List<Collection> getCollectionsForStory(UUID storyId) {
List<CollectionStory> collectionStories = collectionStoryRepository.findByStoryId(storyId);
return collectionStories.stream()
.map(CollectionStory::getCollection)
.collect(Collectors.toList());
}
/**
* Get all collections for indexing (used by TypesenseService)
*/
public List<Collection> findAllForIndexing() {
return collectionRepository.findAllActiveCollections();
}
}

View File

@@ -316,6 +316,36 @@ public class StoryService {
return savedStory; return savedStory;
} }
public Story createWithTagNames(@Valid Story story, java.util.List<String> tagNames) {
validateStoryForCreate(story);
// Set up relationships
if (story.getAuthor() != null && story.getAuthor().getId() != null) {
Author author = authorService.findById(story.getAuthor().getId());
story.setAuthor(author);
}
if (story.getSeries() != null && story.getSeries().getId() != null) {
Series series = seriesService.findById(story.getSeries().getId());
story.setSeries(series);
validateSeriesVolume(series, story.getVolume());
}
Story savedStory = storyRepository.save(story);
// Handle tags by names
if (tagNames != null && !tagNames.isEmpty()) {
updateStoryTagsByNames(savedStory, tagNames);
}
// Index in Typesense (if available)
if (typesenseService != null) {
typesenseService.indexStory(savedStory);
}
return savedStory;
}
public Story update(UUID id, @Valid Story storyUpdates) { public Story update(UUID id, @Valid Story storyUpdates) {
Story existingStory = findById(id); Story existingStory = findById(id);

View File

@@ -4,7 +4,10 @@ import com.storycove.dto.AuthorSearchDto;
import com.storycove.dto.SearchResultDto; import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StorySearchDto; import com.storycove.dto.StorySearchDto;
import com.storycove.entity.Author; import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story; import com.storycove.entity.Story;
import com.storycove.repository.CollectionStoryRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +17,7 @@ import org.typesense.api.Client;
import org.typesense.model.*; import org.typesense.model.*;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -24,12 +28,16 @@ public class TypesenseService {
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class); private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
private static final String STORIES_COLLECTION = "stories"; private static final String STORIES_COLLECTION = "stories";
private static final String AUTHORS_COLLECTION = "authors"; private static final String AUTHORS_COLLECTION = "authors";
private static final String COLLECTIONS_COLLECTION = "collections";
private final Client typesenseClient; private final Client typesenseClient;
private final CollectionStoryRepository collectionStoryRepository;
@Autowired @Autowired
public TypesenseService(Client typesenseClient) { public TypesenseService(Client typesenseClient,
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository) {
this.typesenseClient = typesenseClient; this.typesenseClient = typesenseClient;
this.collectionStoryRepository = collectionStoryRepository;
} }
@PostConstruct @PostConstruct
@@ -37,6 +45,7 @@ public class TypesenseService {
try { try {
createStoriesCollectionIfNotExists(); createStoriesCollectionIfNotExists();
createAuthorsCollectionIfNotExists(); createAuthorsCollectionIfNotExists();
createCollectionsCollectionIfNotExists();
} catch (Exception e) { } catch (Exception e) {
logger.error("Failed to initialize Typesense collections", e); logger.error("Failed to initialize Typesense collections", e);
} }
@@ -177,6 +186,9 @@ public class TypesenseService {
try { try {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
logger.info("SEARCH DEBUG: searchStories called with query='{}', tagFilters={}, authorFilters={}",
query, tagFilters, authorFilters);
// Convert 0-based page (frontend/backend) to 1-based page (Typesense) // Convert 0-based page (frontend/backend) to 1-based page (Typesense)
int typesensePage = page + 1; int typesensePage = page + 1;
@@ -201,15 +213,22 @@ public class TypesenseService {
if (authorFilters != null && !authorFilters.isEmpty()) { if (authorFilters != null && !authorFilters.isEmpty()) {
String authorFilter = authorFilters.stream() String authorFilter = authorFilters.stream()
.map(author -> "authorName:=" + author) .map(author -> "authorName:=" + escapeTypesenseValue(author))
.collect(Collectors.joining(" || ")); .collect(Collectors.joining(" || "));
filterConditions.add("(" + authorFilter + ")"); filterConditions.add("(" + authorFilter + ")");
} }
if (tagFilters != null && !tagFilters.isEmpty()) { if (tagFilters != null && !tagFilters.isEmpty()) {
logger.info("SEARCH DEBUG: Processing {} tag filters: {}", tagFilters.size(), tagFilters);
String tagFilter = tagFilters.stream() String tagFilter = tagFilters.stream()
.map(tag -> "tagNames:=" + tag) .map(tag -> {
String escaped = escapeTypesenseValue(tag);
String condition = "tagNames:=" + escaped;
logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition);
return condition;
})
.collect(Collectors.joining(" || ")); .collect(Collectors.joining(" || "));
logger.info("SEARCH DEBUG: Final tag filter condition: '{}'", tagFilter);
filterConditions.add("(" + tagFilter + ")"); filterConditions.add("(" + tagFilter + ")");
} }
@@ -222,13 +241,19 @@ public class TypesenseService {
} }
if (!filterConditions.isEmpty()) { if (!filterConditions.isEmpty()) {
searchParameters.filterBy(String.join(" && ", filterConditions)); String finalFilter = String.join(" && ", filterConditions);
logger.info("SEARCH DEBUG: Final filter condition: '{}'", finalFilter);
searchParameters.filterBy(finalFilter);
} else {
logger.info("SEARCH DEBUG: No filter conditions applied");
} }
SearchResult searchResult = typesenseClient.collections(STORIES_COLLECTION) SearchResult searchResult = typesenseClient.collections(STORIES_COLLECTION)
.documents() .documents()
.search(searchParameters); .search(searchParameters);
logger.info("SEARCH DEBUG: Typesense returned {} results", searchResult.getFound());
List<StorySearchDto> results = convertSearchResult(searchResult); List<StorySearchDto> results = convertSearchResult(searchResult);
long searchTime = System.currentTimeMillis() - startTime; long searchTime = System.currentTimeMillis() - startTime;
@@ -338,7 +363,10 @@ public class TypesenseService {
List<String> tagNames = story.getTags().stream() List<String> tagNames = story.getTags().stream()
.map(tag -> tag.getName()) .map(tag -> tag.getName())
.collect(Collectors.toList()); .collect(Collectors.toList());
logger.debug("INDEXING DEBUG: Story '{}' has tags: {}", story.getTitle(), tagNames);
document.put("tagNames", tagNames); document.put("tagNames", tagNames);
} else {
logger.debug("INDEXING DEBUG: Story '{}' has no tags", story.getTitle());
} }
document.put("rating", story.getRating() != null ? story.getRating() : 0); document.put("rating", story.getRating() != null ? story.getRating() : 0);
@@ -890,4 +918,314 @@ public class TypesenseService {
return Map.of("error", e.getMessage()); return Map.of("error", e.getMessage());
} }
} }
/**
* Escape special characters in Typesense filter values.
* Typesense requires certain characters to be escaped or quoted.
*/
private String escapeTypesenseValue(String value) {
if (value == null) {
return "";
}
// If the value contains spaces, special characters, or quotes, wrap it in backticks
if (value.contains(" ") || value.contains("'") || value.contains("\"") ||
value.contains("(") || value.contains(")") || value.contains("[") ||
value.contains("]") || value.contains("{") || value.contains("}") ||
value.contains(":") || value.contains("=") || value.contains("&") ||
value.contains("|") || value.contains("!") || value.contains("<") ||
value.contains(">") || value.contains("@") || value.contains("#") ||
value.contains("$") || value.contains("%") || value.contains("^") ||
value.contains("*") || value.contains("+") || value.contains("?") ||
value.contains("\\") || value.contains("/") || value.contains("~") ||
value.contains("`")) {
// Escape backticks in the value and wrap with backticks
return "`" + value.replace("`", "\\`") + "`";
}
return value;
}
// Collections support methods
private void createCollectionsCollectionIfNotExists() throws Exception {
try {
// Check if collection already exists
typesenseClient.collections(COLLECTIONS_COLLECTION).retrieve();
logger.info("Collections collection already exists");
} catch (Exception e) {
logger.info("Creating collections collection...");
createCollectionsCollection();
}
}
private void createCollectionsCollection() throws Exception {
List<Field> fields = Arrays.asList(
new Field().name("id").type("string").facet(false),
new Field().name("name").type("string").facet(false),
new Field().name("description").type("string").facet(false).optional(true),
new Field().name("tags").type("string[]").facet(true).optional(true),
new Field().name("story_count").type("int32").facet(true),
new Field().name("total_word_count").type("int32").facet(true),
new Field().name("rating").type("int32").facet(true).optional(true),
new Field().name("is_archived").type("bool").facet(true),
new Field().name("created_at").type("int64").facet(false),
new Field().name("updated_at").type("int64").facet(false)
);
CollectionSchema collectionSchema = new CollectionSchema()
.name(COLLECTIONS_COLLECTION)
.fields(fields)
.defaultSortingField("updated_at");
typesenseClient.collections().create(collectionSchema);
logger.info("Collections collection created successfully");
}
/**
* Search collections using Typesense
* This is the MANDATORY method for all collection search/filter operations
*/
public SearchResultDto<Collection> searchCollections(String query, List<String> tags, boolean includeArchived, int page, int limit) {
long startTime = System.currentTimeMillis();
try {
String normalizedQuery = (query == null || query.trim().isEmpty()) ? "*" : query.trim();
SearchParameters searchParameters = new SearchParameters()
.q(normalizedQuery)
.queryBy("name,description")
.page(page + 1) // Typesense uses 1-based pagination
.perPage(limit)
.sortBy("updated_at:desc");
// Add filters
List<String> filterConditions = new ArrayList<>();
if (!includeArchived) {
filterConditions.add("is_archived:=false");
}
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tags:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" || "));
filterConditions.add("(" + tagFilter + ")");
}
if (!filterConditions.isEmpty()) {
String finalFilter = String.join(" && ", filterConditions);
searchParameters.filterBy(finalFilter);
}
SearchResult searchResult = typesenseClient.collections(COLLECTIONS_COLLECTION)
.documents()
.search(searchParameters);
List<Collection> results = convertCollectionSearchResult(searchResult);
long searchTime = System.currentTimeMillis() - startTime;
return new SearchResultDto<>(
results,
searchResult.getFound(),
page,
limit,
query != null ? query : "",
searchTime
);
} catch (Exception e) {
logger.error("Collection search failed for query: " + query, e);
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
}
}
/**
* Index a collection in Typesense
*/
public void indexCollection(Collection collection) {
try {
Map<String, Object> document = createCollectionDocument(collection);
typesenseClient.collections(COLLECTIONS_COLLECTION).documents().upsert(document);
logger.debug("Indexed collection: {}", collection.getName());
} catch (Exception e) {
logger.error("Failed to index collection: " + collection.getId(), e);
}
}
/**
* Remove a collection from Typesense index
*/
public void removeCollection(UUID collectionId) {
try {
typesenseClient.collections(COLLECTIONS_COLLECTION).documents(collectionId.toString()).delete();
logger.debug("Removed collection from index: {}", collectionId);
} catch (Exception e) {
logger.error("Failed to remove collection from index: " + collectionId, e);
}
}
/**
* Bulk index collections
*/
public void bulkIndexCollections(List<Collection> collections) {
if (collections == null || collections.isEmpty()) {
return;
}
try {
List<Map<String, Object>> documents = collections.stream()
.map(this::createCollectionDocument)
.collect(Collectors.toList());
for (Map<String, Object> document : documents) {
typesenseClient.collections(COLLECTIONS_COLLECTION).documents().create(document);
}
logger.info("Bulk indexed {} collections", collections.size());
} catch (Exception e) {
logger.error("Failed to bulk index collections", e);
}
}
/**
* Reindex all collections
*/
public void reindexAllCollections(List<Collection> collections) {
try {
// Clear existing collection
try {
typesenseClient.collections(COLLECTIONS_COLLECTION).delete();
} catch (Exception e) {
logger.debug("Collection didn't exist for deletion: {}", e.getMessage());
}
// Recreate collection
createCollectionsCollection();
// Bulk index all collections
bulkIndexCollections(collections);
logger.info("Reindexed {} collections", collections.size());
} catch (Exception e) {
logger.error("Failed to reindex collections", e);
}
}
/**
* Create Typesense document from Collection entity
*/
private Map<String, Object> createCollectionDocument(Collection collection) {
Map<String, Object> document = new HashMap<>();
document.put("id", collection.getId().toString());
document.put("name", collection.getName());
document.put("description", collection.getDescription() != null ? collection.getDescription() : "");
// Tags - safely get tag names without triggering lazy loading issues
List<String> tagNames = new ArrayList<>();
if (collection.getTags() != null) {
try {
tagNames = collection.getTags().stream()
.map(tag -> tag.getName())
.collect(Collectors.toList());
} catch (Exception e) {
logger.warn("Failed to load tags for collection {}, using empty list", collection.getId());
tagNames = new ArrayList<>();
}
}
document.put("tags", tagNames);
// Statistics - calculate safely using repository queries to avoid lazy loading issues
int storyCount = 0;
int totalWordCount = 0;
try {
if (collectionStoryRepository != null) {
// Use repository count instead of accessing entity collection
storyCount = (int) collectionStoryRepository.countByCollectionId(collection.getId());
// For word count, we'll calculate it via a repository query to avoid lazy loading
List<CollectionStory> collectionStories = collectionStoryRepository.findByCollectionIdOrderByPosition(collection.getId());
totalWordCount = collectionStories.stream()
.mapToInt(cs -> {
try {
Integer wordCount = cs.getStory().getWordCount();
return wordCount != null ? wordCount : 0;
} catch (Exception e) {
logger.debug("Failed to get word count for story in collection {}", collection.getId());
return 0;
}
})
.sum();
}
} catch (Exception e) {
logger.warn("Failed to calculate statistics for collection {}, using defaults: {}", collection.getId(), e.getMessage());
storyCount = 0;
totalWordCount = 0;
}
document.put("story_count", storyCount);
document.put("total_word_count", totalWordCount);
document.put("rating", collection.getRating());
document.put("cover_image_path", collection.getCoverImagePath());
document.put("is_archived", collection.getIsArchived() != null ? collection.getIsArchived() : false);
// Timestamps
document.put("created_at", collection.getCreatedAt().toEpochSecond(java.time.ZoneOffset.UTC));
document.put("updated_at", collection.getUpdatedAt().toEpochSecond(java.time.ZoneOffset.UTC));
return document;
}
/**
* Convert Typesense search result to Collection entities
*/
private List<Collection> convertCollectionSearchResult(SearchResult searchResult) {
List<Collection> collections = new ArrayList<>();
if (searchResult.getHits() != null) {
for (SearchResultHit hit : searchResult.getHits()) {
try {
Map<String, Object> doc = hit.getDocument();
Collection collection = new Collection();
collection.setId(UUID.fromString((String) doc.get("id")));
collection.setName((String) doc.get("name"));
collection.setDescription((String) doc.get("description"));
collection.setRating(doc.get("rating") != null ? ((Number) doc.get("rating")).intValue() : null);
collection.setCoverImagePath((String) doc.get("cover_image_path"));
collection.setIsArchived((Boolean) doc.get("is_archived"));
// Set timestamps
if (doc.get("created_at") != null) {
long createdAtSeconds = ((Number) doc.get("created_at")).longValue();
collection.setCreatedAt(LocalDateTime.ofEpochSecond(createdAtSeconds, 0, java.time.ZoneOffset.UTC));
}
if (doc.get("updated_at") != null) {
long updatedAtSeconds = ((Number) doc.get("updated_at")).longValue();
collection.setUpdatedAt(LocalDateTime.ofEpochSecond(updatedAtSeconds, 0, java.time.ZoneOffset.UTC));
}
// For list/search views, we create a special lightweight collection that stores
// the calculated values directly to avoid lazy loading issues
CollectionSearchResult searchCollection = new CollectionSearchResult(collection);
// Set the calculated statistics from the Typesense document
if (doc.get("story_count") != null) {
searchCollection.setStoredStoryCount(((Number) doc.get("story_count")).intValue());
}
if (doc.get("total_word_count") != null) {
searchCollection.setStoredTotalWordCount(((Number) doc.get("total_word_count")).intValue());
}
collections.add(searchCollection);
} catch (Exception e) {
logger.error("Error converting collection search result", e);
}
}
}
return collections;
}
} }

View File

@@ -23,6 +23,10 @@ server:
port: 8080 port: 8080
storycove: storycove:
app:
public-url: ${STORYCOVE_PUBLIC_URL:http://localhost:6925}
cors:
allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925}
jwt: jwt:
secret: ${JWT_SECRET:default-secret-key} secret: ${JWT_SECRET:default-secret-key}
expiration: 86400000 # 24 hours expiration: 86400000 # 24 hours

View File

@@ -35,8 +35,7 @@
"summary": ["class", "style"] "summary": ["class", "style"]
}, },
"allowedCssProperties": [ "allowedCssProperties": [
"color", "background-color", "font-size", "font-weight", "font-weight", "font-style", "text-align", "text-decoration", "margin",
"font-style", "text-align", "text-decoration", "margin",
"padding", "text-indent", "line-height" "padding", "text-indent", "line-height"
], ],
"removedAttributes": { "removedAttributes": {

35
deploy.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# StoryCove Deployment Script
# Usage: ./deploy.sh [environment]
# Environments: development, staging, production
set -e
ENVIRONMENT=${1:-development}
ENV_FILE=".env.${ENVIRONMENT}"
echo "Deploying StoryCove for ${ENVIRONMENT} environment..."
# Check if environment file exists
if [ ! -f "$ENV_FILE" ]; then
echo "Error: Environment file $ENV_FILE not found."
echo "Available environments: development, staging, production"
exit 1
fi
# Copy environment file to .env
cp "$ENV_FILE" .env
echo "Using environment configuration from $ENV_FILE"
# Build and start services
echo "Building and starting Docker services..."
docker-compose down
docker-compose build --no-cache
docker-compose up -d
echo "Deployment complete!"
echo "StoryCove is running at: $(grep STORYCOVE_PUBLIC_URL $ENV_FILE | cut -d'=' -f2)"
echo ""
echo "To view logs: docker-compose logs -f"
echo "To stop: docker-compose down"

View File

@@ -21,7 +21,7 @@ services:
frontend: frontend:
build: ./frontend build: ./frontend
environment: environment:
- NEXT_PUBLIC_API_URL=http://backend:8080/api - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-/api}
depends_on: depends_on:
- backend - backend
networks: networks:
@@ -39,7 +39,7 @@ services:
- TYPESENSE_PORT=8108 - TYPESENSE_PORT=8108
- IMAGE_STORAGE_PATH=/app/images - IMAGE_STORAGE_PATH=/app/images
- APP_PASSWORD=${APP_PASSWORD} - APP_PASSWORD=${APP_PASSWORD}
- STORYCOVE_CORS_ALLOWED_ORIGINS=https://storycove.sharyavin.synology.me,http://localhost:3000 - STORYCOVE_CORS_ALLOWED_ORIGINS=${STORYCOVE_CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:6925}
volumes: volumes:
- images_data:/app/images - images_data:/app/images
depends_on: depends_on:

1056
docs/API.md Normal file

File diff suppressed because it is too large Load Diff

263
docs/DATA_MODEL.md Normal file
View File

@@ -0,0 +1,263 @@
# StoryCove Data Model Documentation
## Overview
StoryCove uses PostgreSQL as its primary database with UUID-based primary keys throughout. The data model is designed to support a personal library of short stories with rich metadata, author information, and flexible organization through tags and series.
## Entity Relationship Diagram
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Authors │────│ Stories │────│ Series │
│ │ │ │ │ │
│ - id (PK) │ │ - id (PK) │ │ - id (PK) │
│ - name │ │ - title │ │ - name │
│ - notes │ │ - content* │ │ - desc │
│ - rating │ │ - rating │ │ │
│ - avatar │ │ - volume │ │ │
└─────────────┘ │ - cover │ └─────────────┘
│ │ - word_count │
│ │ - source_url │
│ │ - timestamps │
│ └──────────────┘
│ │
│ │
┌─────────────┐ │ ┌─────────────┐
│ Author_URLs │ │ │ Tags │
│ │ │ │ │
│ - author_id │ │ │ - id (PK) │
│ - url │ │ │ - name │
└─────────────┘ │ └─────────────┘
│ │
│ │
┌─────────────┐ │
│ Story_Tags │─────────┘
│ │
│ - story_id │
│ - tag_id │
└─────────────┘
```
## Detailed Entity Specifications
### Stories Table
**Table Name**: `stories`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PRIMARY KEY, NOT NULL | Unique identifier |
| title | VARCHAR(255) | NOT NULL | Story title |
| summary | TEXT | NULL | Optional story summary |
| description | VARCHAR(1000) | NULL | Optional description |
| content_html | TEXT | NULL | HTML content of the story |
| content_plain | TEXT | NULL | Plain text version (auto-generated) |
| source_url | VARCHAR(255) | NULL | Source URL where story was found |
| cover_path | VARCHAR(255) | NULL | Path to cover image file |
| word_count | INTEGER | NOT NULL, DEFAULT 0 | Word count (auto-calculated) |
| rating | INTEGER | NULL, CHECK (rating >= 1 AND rating <= 5) | Story rating |
| volume | INTEGER | NULL | Volume number if part of series |
| author_id | UUID | FOREIGN KEY | Reference to authors table |
| series_id | UUID | FOREIGN KEY, NULL | Reference to series table |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Creation timestamp |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Last update timestamp |
**Indexes:**
- Primary key on `id`
- Foreign key index on `author_id`
- Foreign key index on `series_id`
- Index on `created_at` for recent stories queries
- Index on `rating` for top-rated queries
- Unique constraint on `source_url` where not null
**Business Rules:**
- Word count is automatically calculated from `content_plain` or `content_html`
- Plain text content is automatically extracted from HTML content using Jsoup
- Volume is only meaningful when series_id is set
- Rating must be between 1-5 if provided
### Authors Table
**Table Name**: `authors`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PRIMARY KEY, NOT NULL | Unique identifier |
| name | VARCHAR(255) | NOT NULL, UNIQUE | Author name |
| notes | TEXT | NULL | Notes about the author |
| author_rating | INTEGER | NULL, CHECK (author_rating >= 1 AND author_rating <= 5) | Author rating |
| avatar_image_path | VARCHAR(255) | NULL | Path to avatar image |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Creation timestamp |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Last update timestamp |
**Indexes:**
- Primary key on `id`
- Unique index on `name`
- Index on `author_rating` for top-rated queries
**Business Rules:**
- Author names must be unique across the system
- Rating must be between 1-5 if provided
- Author statistics (story count, average rating) are calculated dynamically
### Author URLs Table
**Table Name**: `author_urls`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| author_id | UUID | FOREIGN KEY, NOT NULL | Reference to authors table |
| url | VARCHAR(255) | NOT NULL | URL associated with author |
**Indexes:**
- Foreign key index on `author_id`
- Composite index on `(author_id, url)` for uniqueness
**Business Rules:**
- One author can have multiple URLs
- URLs are stored as simple strings without validation
- Duplicate URLs for the same author are prevented by application logic
### Series Table
**Table Name**: `series`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PRIMARY KEY, NOT NULL | Unique identifier |
| name | VARCHAR(255) | NOT NULL, UNIQUE | Series name |
| description | VARCHAR(1000) | NULL | Series description |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Creation timestamp |
**Indexes:**
- Primary key on `id`
- Unique index on `name`
**Business Rules:**
- Series names must be unique
- Stories in a series are ordered by volume number
- Series without stories are allowed (placeholder series)
### Tags Table
**Table Name**: `tags`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PRIMARY KEY, NOT NULL | Unique identifier |
| name | VARCHAR(100) | NOT NULL, UNIQUE | Tag name |
| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | Creation timestamp |
**Indexes:**
- Primary key on `id`
- Unique index on `name`
- Index on `name` for autocomplete queries
**Business Rules:**
- Tag names must be unique and are stored in lowercase
- Tags are created automatically when referenced by stories
- Tag usage statistics are calculated dynamically
### Story Tags Junction Table
**Table Name**: `story_tags`
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| story_id | UUID | FOREIGN KEY, NOT NULL | Reference to stories table |
| tag_id | UUID | FOREIGN KEY, NOT NULL | Reference to tags table |
**Constraints:**
- Primary key on `(story_id, tag_id)`
- Foreign key to `stories(id)` with CASCADE DELETE
- Foreign key to `tags(id)` with CASCADE DELETE
**Indexes:**
- Composite primary key index
- Index on `tag_id` for reverse lookups
## Data Types and Conventions
### UUID Strategy
- All primary keys use UUID (Universally Unique Identifier)
- Generated using `GenerationType.UUID` in Hibernate
- Provides natural uniqueness across distributed systems
- 36-character string representation (e.g., `123e4567-e89b-12d3-a456-426614174000`)
### Timestamp Management
- All entities have `created_at` timestamp
- Stories and Authors have `updated_at` timestamp (automatically updated)
- Series and Tags only have `created_at` (they're rarely modified)
- All timestamps use `LocalDateTime` in Java, stored as `TIMESTAMP` in PostgreSQL
### Text Fields
- **VARCHAR(n)**: For constrained text fields (names, paths, URLs)
- **TEXT**: For unlimited text content (story content, notes, descriptions)
- **HTML Content**: Stored as-is but sanitized on input and output
- **Plain Text**: Automatically extracted from HTML using Jsoup
### Validation Rules
- **Required Fields**: Entity names/titles are always required
- **Length Limits**: Names limited to 255 characters, descriptions to 1000
- **Rating Range**: All ratings constrained to 1-5 range
- **URL Format**: No format validation at database level
- **Uniqueness**: Names are unique within their entity type
## Relationships and Cascading
### One-to-Many Relationships
- **Author → Stories**: Lazy loaded, cascade ALL operations
- **Series → Stories**: Lazy loaded, ordered by volume, cascade ALL
- **Author → Author URLs**: Eager loaded via `@ElementCollection`
### Many-to-Many Relationships
- **Stories ↔ Tags**: Via `story_tags` junction table
- Managed bidirectionally with helper methods
- Cascade DELETE on both sides
### Foreign Key Constraints
- All foreign keys have proper referential integrity
- DELETE operations cascade appropriately
- No orphaned records are allowed
## Performance Considerations
### Indexing Strategy
- Primary keys automatically indexed
- Foreign keys have dedicated indexes
- Frequently queried fields (rating, created_at) are indexed
- Unique constraints automatically create indexes
### Query Optimization
- Lazy loading prevents N+1 queries
- Pagination used for large result sets
- Specialized queries for common access patterns
- Typesense search engine for full-text search (separate from PostgreSQL)
### Data Volume Estimates
- **Stories**: Expected 1K-10K records per user
- **Authors**: Expected 100-1K records per user
- **Tags**: Expected 50-500 records per user
- **Series**: Expected 10-100 records per user
- **Join Tables**: Scale with story count and tagging usage
## Backup and Migration Considerations
### Schema Evolution
- Uses Hibernate `ddl-auto: update` for development
- Production should use controlled migration tools (Flyway/Liquibase)
- UUID keys allow safe data migration between environments
### Data Integrity
- Foreign key constraints ensure referential integrity
- Check constraints validate data ranges
- Application-level validation provides user-friendly error messages
- Unique constraints prevent duplicate data
### Backup Strategy
- Full PostgreSQL dumps for complete backup
- Image files stored separately in filesystem
- Consider incremental backups for large installations
- Test restore procedures regularly
This data model provides a solid foundation for personal story library management with room for future enhancements while maintaining data integrity and performance.

147
docs/README.md Normal file
View File

@@ -0,0 +1,147 @@
# StoryCove Documentation
Welcome to the StoryCove documentation! This directory contains comprehensive documentation for the StoryCove application.
## 📚 Documentation Files
### **[API Documentation](API.md)**
Complete REST API reference with detailed endpoint descriptions, request/response examples, authentication details, and error handling.
**Contents:**
- Authentication endpoints
- Story management (CRUD, search, images)
- Author management (profiles, avatars, URLs)
- Tag system (creation, autocomplete, statistics)
- Series management (organization, navigation)
- File upload/download (images, validation)
- Search and indexing (Typesense integration)
- Configuration endpoints
- Data Transfer Objects (DTOs)
- Error handling and status codes
### **[Data Model Documentation](DATA_MODEL.md)**
Detailed database schema documentation including entity relationships, constraints, indexes, and business rules.
**Contents:**
- Entity Relationship Diagram
- Detailed table specifications
- Field types and constraints
- Relationship mappings
- Performance considerations
- Backup and migration strategy
### **[OpenAPI Specification](openapi.yaml)**
Machine-readable API specification in OpenAPI 3.0 format for API testing tools, code generation, and integration.
**Usage:**
- Import into Postman, Insomnia, or similar tools
- Generate client SDKs
- API documentation hosting (Swagger UI)
- Contract testing
## 🛠️ How to Use This Documentation
### **For Developers**
1. Start with the [API Documentation](API.md) for endpoint details
2. Reference [Data Model](DATA_MODEL.md) for database understanding
3. Use [OpenAPI spec](openapi.yaml) for testing and integration
4. See main [README](../README.md) for setup instructions
### **For API Integration**
1. Review authentication flow in [API docs](API.md)
2. Import [OpenAPI spec](openapi.yaml) into your API client
3. Test endpoints using the provided examples
4. Implement error handling based on documented responses
### **For Database Work**
1. Study [Data Model](DATA_MODEL.md) for schema understanding
2. Review relationship constraints and business rules
3. Use entity diagrams for system architecture planning
4. Follow migration guidelines for schema changes
### **For System Administration**
1. Reference main [README](../README.md) for deployment instructions
2. Use environment configuration examples
3. Review backup strategies in [Data Model](DATA_MODEL.md)
4. Monitor API health using documented endpoints
## 🔗 External Resources
### **Related Documentation**
- **[Main README](../README.md)**: Project overview, setup, and deployment
- **[Technical Specification](../storycove-spec.md)**: Comprehensive technical specification
- **Environment Files**: `.env.example`, `.env.development`, `.env.production`
### **Technology Documentation**
- **[Spring Boot](https://spring.io/projects/spring-boot)**: Backend framework
- **[Next.js](https://nextjs.org/)**: Frontend framework
- **[PostgreSQL](https://www.postgresql.org/docs/)**: Database system
- **[Typesense](https://typesense.org/docs/)**: Search engine
- **[Docker](https://docs.docker.com/)**: Containerization
## 📝 Documentation Standards
### **API Documentation**
- All endpoints documented with examples
- Request/response schemas included
- Error scenarios covered
- Authentication requirements specified
### **Code Examples**
- JSON examples use realistic data
- UUIDs formatted correctly
- Timestamps in ISO 8601 format
- Error responses include helpful messages
### **Data Model**
- All tables and relationships documented
- Constraints and validation rules specified
- Performance implications noted
- Migration considerations included
## 🤝 Contributing to Documentation
When updating the application:
1. **API Changes**: Update [API.md](API.md) and [openapi.yaml](openapi.yaml)
2. **Database Changes**: Update [DATA_MODEL.md](DATA_MODEL.md)
3. **Feature Changes**: Update main [README](../README.md) features section
4. **Deployment Changes**: Update environment configuration examples
### **Documentation Checklist**
- [ ] API endpoints documented with examples
- [ ] Database schema changes reflected
- [ ] OpenAPI specification updated
- [ ] Error handling documented
- [ ] Authentication requirements specified
- [ ] Performance implications noted
- [ ] Migration steps documented
## 📋 Quick Reference
### **Base URLs**
- **Development**: `http://localhost:6925/api`
- **Production**: `https://yourdomain.com/api`
### **Authentication**
- **Method**: JWT tokens via httpOnly cookies
- **Login**: `POST /api/auth/login`
- **Token Expiry**: 24 hours
### **Key Endpoints**
- **Stories**: `/api/stories`
- **Authors**: `/api/authors`
- **Tags**: `/api/tags`
- **Series**: `/api/series`
- **Search**: `/api/stories/search`
- **Files**: `/api/files`
### **Common Response Codes**
- **200**: Success
- **201**: Created
- **400**: Bad Request
- **401**: Unauthorized
- **404**: Not Found
- **500**: Server Error
This documentation is maintained alongside the codebase to ensure accuracy and completeness. For questions or clarifications, please refer to the appropriate documentation file or create an issue in the project repository.

583
docs/openapi.yaml Normal file
View File

@@ -0,0 +1,583 @@
openapi: 3.0.3
info:
title: StoryCove API
description: |
StoryCove is a self-hosted web application for storing, organizing, and reading short stories from various internet sources.
## Features
- Story management with HTML content support
- Author profiles with ratings and metadata
- Tag-based categorization and series organization
- Full-text search powered by Typesense
- Image upload for covers and avatars
- JWT-based authentication
## Authentication
All endpoints (except `/api/auth/login`) require JWT authentication via httpOnly cookies.
version: 1.0.0
contact:
name: StoryCove
license:
name: MIT
servers:
- url: http://localhost:6925/api
description: Local development server
- url: https://storycove.hardegger.io/api
description: Production server
security:
- cookieAuth: []
paths:
# Authentication endpoints
/auth/login:
post:
tags: [Authentication]
summary: Login with password
description: Authenticate with application password and receive JWT token
security: [] # No authentication required
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
description: Application password
responses:
'200':
description: Authentication successful
headers:
Set-Cookie:
description: JWT token cookie
schema:
type: string
example: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Max-Age=86400
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Authentication successful
token:
type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
'401':
$ref: '#/components/responses/Unauthorized'
/auth/logout:
post:
tags: [Authentication]
summary: Logout and clear token
description: Clear authentication token and logout
responses:
'200':
description: Logout successful
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
/auth/verify:
get:
tags: [Authentication]
summary: Verify token validity
description: Check if current JWT token is valid
responses:
'200':
description: Token is valid
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'401':
$ref: '#/components/responses/Unauthorized'
# Story endpoints
/stories:
get:
tags: [Stories]
summary: List all stories
description: Get paginated list of all stories
parameters:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/SizeParam'
- $ref: '#/components/parameters/SortByParam'
- $ref: '#/components/parameters/SortDirParam'
responses:
'200':
description: Stories retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PagedStories'
'401':
$ref: '#/components/responses/Unauthorized'
post:
tags: [Stories]
summary: Create new story
description: Create a new story with optional author, series, and tags
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateStoryRequest'
responses:
'201':
description: Story created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/StoryDto'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/stories/{id}:
get:
tags: [Stories]
summary: Get story by ID
description: Retrieve a specific story by its UUID
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: Story UUID
responses:
'200':
description: Story retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/StoryDto'
'404':
$ref: '#/components/responses/NotFound'
'401':
$ref: '#/components/responses/Unauthorized'
put:
tags: [Stories]
summary: Update story
description: Update an existing story
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateStoryRequest'
responses:
'200':
description: Story updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/StoryDto'
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'401':
$ref: '#/components/responses/Unauthorized'
delete:
tags: [Stories]
summary: Delete story
description: Delete a story and all its relationships
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Story deleted successfully
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'404':
$ref: '#/components/responses/NotFound'
'401':
$ref: '#/components/responses/Unauthorized'
/stories/search:
get:
tags: [Stories]
summary: Search stories
description: Search stories using Typesense full-text search
parameters:
- name: query
in: query
required: true
schema:
type: string
description: Search query
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/SizeParam'
- name: authors
in: query
schema:
type: array
items:
type: string
description: Filter by author names
- name: tags
in: query
schema:
type: array
items:
type: string
description: Filter by tag names
- name: minRating
in: query
schema:
type: integer
minimum: 1
maximum: 5
- name: maxRating
in: query
schema:
type: integer
minimum: 1
maximum: 5
responses:
'200':
description: Search results
content:
application/json:
schema:
$ref: '#/components/schemas/SearchResult'
'401':
$ref: '#/components/responses/Unauthorized'
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: token
parameters:
PageParam:
name: page
in: query
schema:
type: integer
minimum: 0
default: 0
description: Page number (0-based)
SizeParam:
name: size
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Page size
SortByParam:
name: sortBy
in: query
schema:
type: string
default: createdAt
description: Field to sort by
SortDirParam:
name: sortDir
in: query
schema:
type: string
enum: [asc, desc]
default: desc
description: Sort direction
schemas:
StoryDto:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
summary:
type: string
nullable: true
description:
type: string
nullable: true
contentHtml:
type: string
nullable: true
contentPlain:
type: string
nullable: true
sourceUrl:
type: string
nullable: true
coverPath:
type: string
nullable: true
wordCount:
type: integer
rating:
type: integer
minimum: 1
maximum: 5
nullable: true
volume:
type: integer
nullable: true
authorId:
type: string
format: uuid
nullable: true
authorName:
type: string
nullable: true
seriesId:
type: string
format: uuid
nullable: true
seriesName:
type: string
nullable: true
tags:
type: array
items:
$ref: '#/components/schemas/TagDto'
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
CreateStoryRequest:
type: object
required: [title]
properties:
title:
type: string
maxLength: 255
summary:
type: string
description:
type: string
maxLength: 1000
contentHtml:
type: string
sourceUrl:
type: string
volume:
type: integer
authorId:
type: string
format: uuid
authorName:
type: string
seriesId:
type: string
format: uuid
seriesName:
type: string
tagNames:
type: array
items:
type: string
UpdateStoryRequest:
type: object
properties:
title:
type: string
maxLength: 255
summary:
type: string
description:
type: string
maxLength: 1000
contentHtml:
type: string
sourceUrl:
type: string
volume:
type: integer
authorId:
type: string
format: uuid
seriesId:
type: string
format: uuid
tagNames:
type: array
items:
type: string
TagDto:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
storyCount:
type: integer
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
PagedStories:
type: object
properties:
content:
type: array
items:
$ref: '#/components/schemas/StoryDto'
pageable:
$ref: '#/components/schemas/Pageable'
totalElements:
type: integer
totalPages:
type: integer
last:
type: boolean
first:
type: boolean
numberOfElements:
type: integer
size:
type: integer
number:
type: integer
empty:
type: boolean
Pageable:
type: object
properties:
sort:
$ref: '#/components/schemas/Sort'
pageNumber:
type: integer
pageSize:
type: integer
offset:
type: integer
paged:
type: boolean
unpaged:
type: boolean
Sort:
type: object
properties:
sorted:
type: boolean
unsorted:
type: boolean
SearchResult:
type: object
properties:
results:
type: array
items:
$ref: '#/components/schemas/StoryDto'
totalHits:
type: integer
format: int64
page:
type: integer
perPage:
type: integer
query:
type: string
searchTimeMs:
type: integer
format: int64
MessageResponse:
type: object
properties:
message:
type: string
ErrorResponse:
type: object
properties:
error:
type: string
details:
type: object
additionalProperties:
type: string
responses:
BadRequest:
description: Bad request - validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
Unauthorized:
description: Authentication required or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
tags:
- name: Authentication
description: Authentication endpoints
- name: Stories
description: Story management endpoints
- name: Authors
description: Author management endpoints
- name: Tags
description: Tag management endpoints
- name: Series
description: Series management endpoints
- name: Files
description: File upload and management endpoints
- name: Search
description: Search and indexing endpoints
- name: Configuration
description: Application configuration endpoints

View File

@@ -0,0 +1,4 @@
# Frontend Development Environment
# For local development outside of Docker
NEXT_PUBLIC_API_URL=http://localhost:8080/api

4
frontend/.env.production Normal file
View File

@@ -0,0 +1,4 @@
# Frontend Production Environment
# API URL should be relative in Docker setup
NEXT_PUBLIC_API_URL=/api

View File

@@ -132,10 +132,10 @@ export default function EditAuthorPage() {
updateFormData.append('authorRating', formData.authorRating.toString()); updateFormData.append('authorRating', formData.authorRating.toString());
} }
// Add URLs as array // Add URLs as multiple parameters with same name
const validUrls = formData.urls.filter(url => url.trim()); const validUrls = formData.urls.filter(url => url.trim());
validUrls.forEach((url, index) => { validUrls.forEach((url) => {
updateFormData.append(`urls[${index}]`, url); updateFormData.append('urls', url);
}); });
// Add avatar if selected // Add avatar if selected

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { collectionApi } from '../../../../lib/api';
import { Collection } from '../../../../types/api';
import AppLayout from '../../../../components/layout/AppLayout';
import CollectionForm from '../../../../components/collections/CollectionForm';
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
export default function EditCollectionPage() {
const params = useParams();
const router = useRouter();
const collectionId = params.id as string;
const [collection, setCollection] = useState<Collection | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadCollection = async () => {
try {
setLoading(true);
setError(null);
const data = await collectionApi.getCollection(collectionId);
setCollection(data);
} catch (err: any) {
console.error('Failed to load collection:', err);
setError(err.response?.data?.message || 'Failed to load collection');
} finally {
setLoading(false);
}
};
if (collectionId) {
loadCollection();
}
}, [collectionId]);
const handleSubmit = async (formData: {
name: string;
description?: string;
tags?: string[];
storyIds?: string[];
coverImage?: File;
}) => {
if (!collection) return;
try {
setSaving(true);
setError(null);
// Update basic info
await collectionApi.updateCollection(collection.id, {
name: formData.name,
description: formData.description,
tagNames: formData.tags,
});
// Upload cover image if provided
if (formData.coverImage) {
await collectionApi.uploadCover(collection.id, formData.coverImage);
}
// Redirect back to collection detail
router.push(`/collections/${collection.id}`);
} catch (err: any) {
console.error('Failed to update collection:', err);
setError(err.response?.data?.message || 'Failed to update collection');
} finally {
setSaving(false);
}
};
const handleCancel = () => {
router.push(`/collections/${collectionId}`);
};
if (loading) {
return (
<AppLayout>
<div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
</AppLayout>
);
}
if (error || !collection) {
return (
<AppLayout>
<div className="text-center py-20">
<div className="text-red-600 text-lg mb-4">
{error || 'Collection not found'}
</div>
<button
onClick={() => router.push('/collections')}
className="theme-accent hover:underline"
>
Back to Collections
</button>
</div>
</AppLayout>
);
}
const initialData = {
name: collection.name,
description: collection.description,
tags: collection.tags?.map(tag => tag.name) || [],
storyIds: collection.collectionStories?.map(cs => cs.story.id) || [],
coverImagePath: collection.coverImagePath,
};
return (
<AppLayout>
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold theme-header">Edit Collection</h1>
<p className="theme-text mt-2">
Update your collection details and organization.
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-100 border border-red-300 text-red-700 rounded-lg">
{error}
</div>
)}
<CollectionForm
initialData={initialData}
onSubmit={handleSubmit}
onCancel={handleCancel}
loading={saving}
submitLabel="Update Collection"
/>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { collectionApi } from '../../../lib/api';
import { Collection } from '../../../types/api';
import AppLayout from '../../../components/layout/AppLayout';
import CollectionDetailView from '../../../components/collections/CollectionDetailView';
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
export default function CollectionDetailPage() {
const params = useParams();
const router = useRouter();
const collectionId = params.id as string;
const [collection, setCollection] = useState<Collection | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadCollection = async () => {
try {
setLoading(true);
setError(null);
const data = await collectionApi.getCollection(collectionId);
setCollection(data);
} catch (err: any) {
console.error('Failed to load collection:', err);
setError(err.response?.data?.message || 'Failed to load collection');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (collectionId) {
loadCollection();
}
}, [collectionId]);
const handleCollectionUpdate = () => {
loadCollection();
};
const handleCollectionDelete = () => {
router.push('/collections');
};
if (loading) {
return (
<AppLayout>
<div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
</AppLayout>
);
}
if (error || !collection) {
return (
<AppLayout>
<div className="text-center py-20">
<div className="text-red-600 text-lg mb-4">
{error || 'Collection not found'}
</div>
<button
onClick={() => router.push('/collections')}
className="theme-accent hover:underline"
>
Back to Collections
</button>
</div>
</AppLayout>
);
}
return (
<AppLayout>
<CollectionDetailView
collection={collection}
onUpdate={handleCollectionUpdate}
onDelete={handleCollectionDelete}
/>
</AppLayout>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { collectionApi } from '../../../../../lib/api';
import { StoryWithCollectionContext } from '../../../../../types/api';
import AppLayout from '../../../../../components/layout/AppLayout';
import CollectionReadingView from '../../../../../components/collections/CollectionReadingView';
import LoadingSpinner from '../../../../../components/ui/LoadingSpinner';
export default function CollectionReadingPage() {
const params = useParams();
const router = useRouter();
const collectionId = params.id as string;
const storyId = params.storyId as string;
const [data, setData] = useState<StoryWithCollectionContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadStoryWithContext = async () => {
if (!collectionId || !storyId) return;
try {
setLoading(true);
setError(null);
const result = await collectionApi.getStoryWithCollectionContext(collectionId, storyId);
setData(result);
} catch (err: any) {
console.error('Failed to load story with collection context:', err);
setError(err.response?.data?.message || 'Failed to load story');
} finally {
setLoading(false);
}
};
loadStoryWithContext();
}, [collectionId, storyId]);
const handleNavigate = (newStoryId: string) => {
router.push(`/collections/${collectionId}/read/${newStoryId}`);
};
if (loading) {
return (
<AppLayout>
<div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
</AppLayout>
);
}
if (error || !data) {
return (
<AppLayout>
<div className="text-center py-20">
<div className="text-red-600 text-lg mb-4">
{error || 'Story not found'}
</div>
<button
onClick={() => router.push(`/collections/${collectionId}`)}
className="theme-accent hover:underline"
>
Back to Collection
</button>
</div>
</AppLayout>
);
}
return (
<AppLayout>
<CollectionReadingView
data={data}
onNavigate={handleNavigate}
onBackToCollection={() => router.push(`/collections/${collectionId}`)}
/>
</AppLayout>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { collectionApi } from '../../../lib/api';
import AppLayout from '../../../components/layout/AppLayout';
import CollectionForm from '../../../components/collections/CollectionForm';
import { Collection } from '../../../types/api';
export default function NewCollectionPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleSubmit = async (formData: {
name: string;
description?: string;
tags?: string[];
storyIds?: string[];
coverImage?: File;
}) => {
try {
setLoading(true);
setError(null);
let collection: Collection;
if (formData.coverImage) {
collection = await collectionApi.createCollectionWithImage({
name: formData.name,
description: formData.description,
tags: formData.tags,
storyIds: formData.storyIds,
coverImage: formData.coverImage,
});
} else {
collection = await collectionApi.createCollection({
name: formData.name,
description: formData.description,
tagNames: formData.tags,
storyIds: formData.storyIds,
});
}
// Redirect to the new collection's detail page
router.push(`/collections/${collection.id}`);
} catch (err: any) {
console.error('Failed to create collection:', err);
setError(err.response?.data?.message || 'Failed to create collection');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
router.push('/collections');
};
return (
<AppLayout>
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold theme-header">Create New Collection</h1>
<p className="theme-text mt-2">
Organize your stories into a curated collection for better reading experience.
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-100 border border-red-300 text-red-700 rounded-lg">
{error}
</div>
)}
<CollectionForm
onSubmit={handleSubmit}
onCancel={handleCancel}
loading={loading}
submitLabel="Create Collection"
/>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import { useState, useEffect } from 'react';
import { collectionApi, tagApi } from '../../lib/api';
import { Collection, Tag } from '../../types/api';
import AppLayout from '../../components/layout/AppLayout';
import { Input } from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import CollectionGrid from '../../components/collections/CollectionGrid';
import TagFilter from '../../components/stories/TagFilter';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
type ViewMode = 'grid' | 'list';
export default function CollectionsPage() {
const [collections, setCollections] = useState<Collection[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [showArchived, setShowArchived] = useState(false);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(20);
const [totalPages, setTotalPages] = useState(1);
const [totalCollections, setTotalCollections] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Load tags for filtering
useEffect(() => {
const loadTags = async () => {
try {
const tagsResult = await tagApi.getTags({ page: 0, size: 1000 });
setTags(tagsResult?.content || []);
} catch (error) {
console.error('Failed to load tags:', error);
}
};
loadTags();
}, []);
// Load collections with search and filters
useEffect(() => {
const debounceTimer = setTimeout(() => {
const loadCollections = async () => {
try {
setLoading(true);
const result = await collectionApi.getCollections({
page: page,
limit: pageSize,
search: searchQuery.trim() || undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
archived: showArchived,
});
setCollections(result?.results || []);
setTotalPages(Math.ceil((result?.totalHits || 0) / pageSize));
setTotalCollections(result?.totalHits || 0);
} catch (error) {
console.error('Failed to load collections:', error);
setCollections([]);
} finally {
setLoading(false);
}
};
loadCollections();
}, searchQuery ? 300 : 0); // Debounce search, but not other changes
return () => clearTimeout(debounceTimer);
}, [searchQuery, selectedTags, page, pageSize, showArchived, refreshTrigger]);
// Reset page when search or filters change
const resetPage = () => {
if (page !== 0) {
setPage(0);
}
};
const handleTagToggle = (tagName: string) => {
setSelectedTags(prev => {
const newTags = prev.includes(tagName)
? prev.filter(t => t !== tagName)
: [...prev, tagName];
resetPage();
return newTags;
});
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
resetPage();
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
resetPage();
};
const clearFilters = () => {
setSearchQuery('');
setSelectedTags([]);
setShowArchived(false);
resetPage();
};
const handleCollectionUpdate = () => {
// Trigger reload by incrementing refresh trigger
setRefreshTrigger(prev => prev + 1);
};
if (loading && collections.length === 0) {
return (
<AppLayout>
<div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold theme-header">Collections</h1>
<p className="theme-text mt-1">
{totalCollections} {totalCollections === 1 ? 'collection' : 'collections'}
{searchQuery || selectedTags.length > 0 || showArchived ? ` found` : ` total`}
</p>
</div>
<Button href="/collections/new">
Create New Collection
</Button>
</div>
{/* Search and Filters */}
<div className="space-y-4">
{/* Search Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
type="search"
placeholder="Search collections by name or description..."
value={searchQuery}
onChange={handleSearchChange}
className="w-full"
/>
</div>
{/* View Mode Toggle */}
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'grid'
? 'theme-accent-bg text-white'
: 'theme-card theme-text hover:bg-opacity-80'
}`}
aria-label="Grid view"
>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'list'
? 'theme-accent-bg text-white'
: 'theme-card theme-text hover:bg-opacity-80'
}`}
aria-label="List view"
>
</button>
</div>
</div>
{/* Filters and Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
{/* Page Size Selector */}
<div className="flex items-center gap-2">
<label className="theme-text font-medium text-sm">Show:</label>
<select
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="px-3 py-1 rounded-lg theme-card theme-text theme-border border focus:outline-none focus:ring-2 focus:ring-theme-accent"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
{/* Archive Toggle */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => {
setShowArchived(e.target.checked);
resetPage();
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="theme-text text-sm">Show archived</span>
</label>
{/* Clear Filters */}
{(searchQuery || selectedTags.length > 0 || showArchived) && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear Filters
</Button>
)}
</div>
{/* Tag Filter */}
<TagFilter
tags={tags}
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
</div>
{/* Collections Display */}
<CollectionGrid
collections={collections}
viewMode={viewMode}
onUpdate={handleCollectionUpdate}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<Button
variant="ghost"
onClick={() => setPage(page - 1)}
disabled={page === 0}
>
Previous
</Button>
<div className="flex items-center gap-2">
<span className="theme-text text-sm">Page</span>
<input
type="number"
min={1}
max={totalPages}
value={page + 1}
onChange={(e) => {
const newPage = Math.max(0, Math.min(totalPages - 1, parseInt(e.target.value) - 1));
if (!isNaN(newPage)) {
setPage(newPage);
}
}}
className="w-16 px-2 py-1 text-center rounded theme-card theme-text theme-border border focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
<span className="theme-text text-sm">of {totalPages}</span>
</div>
<Button
variant="ghost"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
>
Next
</Button>
</div>
)}
{/* Loading Overlay */}
{loading && collections.length > 0 && (
<div className="fixed inset-0 bg-black bg-opacity-20 flex items-center justify-center z-50">
<div className="theme-card p-4 rounded-lg">
<LoadingSpinner size="md" />
</div>
</div>
)}
</div>
</AppLayout>
);
}

View File

@@ -6,7 +6,7 @@ import { Story, Tag } from '../../types/api';
import AppLayout from '../../components/layout/AppLayout'; import AppLayout from '../../components/layout/AppLayout';
import { Input } from '../../components/ui/Input'; import { Input } from '../../components/ui/Input';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import StoryCard from '../../components/stories/StoryCard'; import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
import TagFilter from '../../components/stories/TagFilter'; import TagFilter from '../../components/stories/TagFilter';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import LoadingSpinner from '../../components/ui/LoadingSpinner';
@@ -242,20 +242,12 @@ export default function LibraryPage() {
)} )}
</div> </div>
) : ( ) : (
<div className={ <StoryMultiSelect
viewMode === 'grid' stories={stories}
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6' viewMode={viewMode}
: 'space-y-4' onUpdate={handleStoryUpdate}
}> allowMultiSelect={true}
{stories.map((story) => ( />
<StoryCard
key={story.id}
story={story}
viewMode={viewMode}
onUpdate={handleStoryUpdate}
/>
))}
</div>
)} )}
{/* Pagination */} {/* Pagination */}

View File

@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { storyApi, seriesApi, getImageUrl } from '../../../../lib/api'; import { storyApi, seriesApi, getImageUrl } from '../../../../lib/api';
import { Story } from '../../../../types/api'; import { Story, Collection } from '../../../../types/api';
import AppLayout from '../../../../components/layout/AppLayout'; import AppLayout from '../../../../components/layout/AppLayout';
import Button from '../../../../components/ui/Button'; import Button from '../../../../components/ui/Button';
import LoadingSpinner from '../../../../components/ui/LoadingSpinner'; import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
@@ -17,6 +17,7 @@ export default function StoryDetailPage() {
const [story, setStory] = useState<Story | null>(null); const [story, setStory] = useState<Story | null>(null);
const [seriesStories, setSeriesStories] = useState<Story[]>([]); const [seriesStories, setSeriesStories] = useState<Story[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
@@ -32,6 +33,10 @@ export default function StoryDetailPage() {
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId); const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
setSeriesStories(seriesData); setSeriesStories(seriesData);
} }
// Load collections that contain this story
const collectionsData = await storyApi.getStoryCollections(storyId);
setCollections(collectionsData);
} catch (error) { } catch (error) {
console.error('Failed to load story data:', error); console.error('Failed to load story data:', error);
router.push('/library'); router.push('/library');
@@ -250,6 +255,57 @@ export default function StoryDetailPage() {
</div> </div>
)} )}
{/* Collections */}
{collections.length > 0 && (
<div className="theme-card theme-shadow rounded-lg p-4">
<h3 className="font-semibold theme-header mb-3">
Part of Collections ({collections.length})
</h3>
<div className="space-y-2">
{collections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.id}`}
className="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex items-center gap-3">
{collection.coverImagePath ? (
<img
src={getImageUrl(collection.coverImagePath)}
alt={`${collection.name} cover`}
className="w-8 h-10 object-cover rounded"
/>
) : (
<div className="w-8 h-10 bg-gradient-to-br from-blue-100 to-purple-100 rounded flex items-center justify-center">
<span className="text-xs font-bold text-gray-600">
{collection.storyCount}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h4 className="font-medium theme-header truncate">
{collection.name}
</h4>
<p className="text-sm theme-text opacity-70">
{collection.storyCount} {collection.storyCount === 1 ? 'story' : 'stories'}
{collection.estimatedReadingTime && (
<span> ~{Math.ceil(collection.estimatedReadingTime / 60)}h reading</span>
)}
</p>
</div>
{collection.rating && (
<div className="flex-shrink-0">
<span className="text-yellow-400"></span>
<span className="text-sm theme-text ml-1">{collection.rating}</span>
</div>
)}
</div>
</Link>
))}
</div>
</div>
)}
{/* Summary */} {/* Summary */}
{story.summary && ( {story.summary && (
<div className="theme-card theme-shadow rounded-lg p-6"> <div className="theme-card theme-shadow rounded-lg p-6">

View File

@@ -0,0 +1,201 @@
'use client';
import { useState, useEffect } from 'react';
import { collectionApi, searchApi } from '../../lib/api';
import { Collection, Story } from '../../types/api';
import Button from '../ui/Button';
import { Input } from '../ui/Input';
import LoadingSpinner from '../ui/LoadingSpinner';
interface AddToCollectionModalProps {
isOpen: boolean;
onClose: () => void;
collection: Collection;
onUpdate: () => void;
}
export default function AddToCollectionModal({
isOpen,
onClose,
collection,
onUpdate
}: AddToCollectionModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [availableStories, setAvailableStories] = useState<Story[]>([]);
const [selectedStoryIds, setSelectedStoryIds] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState(false);
// Get IDs of stories already in the collection
const existingStoryIds = collection.collectionStories?.map(cs => cs.story.id) || [];
useEffect(() => {
if (isOpen) {
loadStories();
}
}, [isOpen, searchQuery]);
const loadStories = async () => {
try {
setLoading(true);
const result = await searchApi.search({
query: searchQuery || '*',
page: 0,
size: 50,
});
// Filter out stories already in the collection
const filteredStories = result.results.filter(
story => !existingStoryIds.includes(story.id)
);
setAvailableStories(filteredStories);
} catch (error) {
console.error('Failed to load stories:', error);
} finally {
setLoading(false);
}
};
const toggleStorySelection = (storyId: string) => {
setSelectedStoryIds(prev =>
prev.includes(storyId)
? prev.filter(id => id !== storyId)
: [...prev, storyId]
);
};
const handleAddStories = async () => {
if (selectedStoryIds.length === 0) return;
try {
setAdding(true);
await collectionApi.addStoriesToCollection(collection.id, selectedStoryIds);
onUpdate();
onClose();
setSelectedStoryIds([]);
} catch (error) {
console.error('Failed to add stories to collection:', error);
} finally {
setAdding(false);
}
};
const handleClose = () => {
if (!adding) {
setSelectedStoryIds([]);
setSearchQuery('');
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="theme-card max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b theme-border">
<h2 className="text-xl font-semibold theme-header">
Add Stories to "{collection.name}"
</h2>
<button
onClick={handleClose}
disabled={adding}
className="text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
×
</button>
</div>
{/* Search */}
<div className="p-6 border-b theme-border">
<Input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search stories to add..."
className="w-full"
/>
</div>
{/* Stories List */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex justify-center py-8">
<LoadingSpinner size="md" />
</div>
) : availableStories.length === 0 ? (
<div className="text-center py-8 theme-text opacity-70">
{searchQuery ? 'No stories found matching your search.' : 'All stories are already in this collection.'}
</div>
) : (
<div className="space-y-3">
{availableStories.map((story) => {
const isSelected = selectedStoryIds.includes(story.id);
return (
<div
key={story.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
isSelected
? 'border-blue-500 bg-blue-50'
: 'theme-border hover:border-gray-400'
}`}
onClick={() => toggleStorySelection(story.id)}
>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStorySelection(story.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<h3 className="font-medium theme-text truncate">
{story.title}
</h3>
<p className="text-sm theme-text opacity-70 truncate">
by {story.authorName}
</p>
<div className="flex items-center gap-4 mt-1 text-xs theme-text opacity-60">
<span>{story.wordCount?.toLocaleString()} words</span>
{story.rating && (
<span className="flex items-center">
{story.rating}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t theme-border">
<div className="text-sm theme-text opacity-70">
{selectedStoryIds.length} stories selected
</div>
<div className="flex gap-3">
<Button
variant="ghost"
onClick={handleClose}
disabled={adding}
>
Cancel
</Button>
<Button
onClick={handleAddStories}
disabled={selectedStoryIds.length === 0 || adding}
>
{adding ? <LoadingSpinner size="sm" /> : `Add ${selectedStoryIds.length} Stories`}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import { Collection } from '../../types/api';
import { getImageUrl } from '../../lib/api';
import Link from 'next/link';
interface CollectionCardProps {
collection: Collection;
viewMode: 'grid' | 'list';
onUpdate?: () => void;
}
export default function CollectionCard({ collection, viewMode, onUpdate }: CollectionCardProps) {
const formatReadingTime = (minutes: number): string => {
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
const renderRatingStars = (rating?: number) => {
if (!rating) return null;
return (
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-sm ${
star <= rating ? 'text-yellow-400' : 'text-gray-300'
}`}
>
</span>
))}
</div>
);
};
if (viewMode === 'grid') {
return (
<Link href={`/collections/${collection.id}`}>
<div className="theme-card p-4 hover:border-gray-400 transition-colors cursor-pointer">
{/* Cover Image or Placeholder */}
<div className="aspect-[3/4] mb-3 relative overflow-hidden rounded-lg bg-gray-100">
{collection.coverImagePath ? (
<img
src={getImageUrl(collection.coverImagePath)}
alt={`${collection.name} cover`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
<div className="text-center p-4">
<div className="text-2xl font-bold theme-text mb-1">
{collection.storyCount}
</div>
<div className="text-xs theme-text opacity-60">
{collection.storyCount === 1 ? 'story' : 'stories'}
</div>
</div>
</div>
)}
{collection.isArchived && (
<div className="absolute top-2 right-2 bg-yellow-500 text-white px-2 py-1 rounded text-xs">
Archived
</div>
)}
</div>
{/* Collection Info */}
<div className="space-y-2">
<h3 className="font-semibold theme-header line-clamp-2">
{collection.name}
</h3>
{collection.description && (
<p className="text-sm theme-text opacity-70 line-clamp-2">
{collection.description}
</p>
)}
<div className="flex items-center justify-between text-xs theme-text opacity-60">
<span>{collection.storyCount} stories</span>
<span>{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'}</span>
</div>
{collection.rating && (
<div className="flex justify-center">
{renderRatingStars(collection.rating)}
</div>
)}
{/* Tags */}
{collection.tags && collection.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{collection.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
>
{tag.name}
</span>
))}
{collection.tags.length > 3 && (
<span className="text-xs theme-text opacity-60">
+{collection.tags.length - 3} more
</span>
)}
</div>
)}
</div>
</div>
</Link>
);
}
// List view
return (
<Link href={`/collections/${collection.id}`}>
<div className="theme-card p-4 hover:border-gray-400 transition-colors cursor-pointer">
<div className="flex gap-4">
{/* Cover Image */}
<div className="w-16 h-20 flex-shrink-0 rounded overflow-hidden bg-gray-100">
{collection.coverImagePath ? (
<img
src={getImageUrl(collection.coverImagePath)}
alt={`${collection.name} cover`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
<div className="text-center">
<div className="text-sm font-bold theme-text">
{collection.storyCount}
</div>
</div>
</div>
)}
</div>
{/* Collection Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold theme-header line-clamp-1">
{collection.name}
{collection.isArchived && (
<span className="ml-2 inline-block bg-yellow-500 text-white px-2 py-1 rounded text-xs">
Archived
</span>
)}
</h3>
{collection.description && (
<p className="text-sm theme-text opacity-70 line-clamp-2 mt-1">
{collection.description}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm theme-text opacity-60">
<span>{collection.storyCount} stories</span>
<span>{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'} reading</span>
{collection.averageStoryRating && collection.averageStoryRating > 0 && (
<span> {collection.averageStoryRating.toFixed(1)} avg</span>
)}
</div>
{/* Tags */}
{collection.tags && collection.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{collection.tags.slice(0, 5).map((tag) => (
<span
key={tag.id}
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
>
{tag.name}
</span>
))}
{collection.tags.length > 5 && (
<span className="text-xs theme-text opacity-60">
+{collection.tags.length - 5} more
</span>
)}
</div>
)}
</div>
{collection.rating && (
<div className="flex-shrink-0 ml-4">
{renderRatingStars(collection.rating)}
</div>
)}
</div>
</div>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,360 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Collection } from '../../types/api';
import { collectionApi, getImageUrl } from '../../lib/api';
import Button from '../ui/Button';
import StoryReorderList from './StoryReorderList';
import AddToCollectionModal from './AddToCollectionModal';
import LoadingSpinner from '../ui/LoadingSpinner';
import Link from 'next/link';
interface CollectionDetailViewProps {
collection: Collection;
onUpdate: () => void;
onDelete: () => void;
}
export default function CollectionDetailView({
collection,
onUpdate,
onDelete
}: CollectionDetailViewProps) {
const router = useRouter();
const [showAddStories, setShowAddStories] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(collection.name);
const [editDescription, setEditDescription] = useState(collection.description || '');
const [editRating, setEditRating] = useState(collection.rating || '');
const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const formatReadingTime = (minutes: number): string => {
if (minutes < 60) {
return `${minutes} minutes`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours} hours`;
};
const renderRatingStars = (rating?: number) => {
if (!rating) return null;
return (
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-lg ${
star <= rating ? 'text-yellow-400' : 'text-gray-300'
}`}
>
</span>
))}
</div>
);
};
const handleSaveEdits = async () => {
try {
setSaving(true);
await collectionApi.updateCollection(collection.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
rating: editRating ? parseInt(editRating.toString()) : undefined,
});
setIsEditing(false);
onUpdate();
} catch (error) {
console.error('Failed to update collection:', error);
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditName(collection.name);
setEditDescription(collection.description || '');
setEditRating(collection.rating || '');
setIsEditing(false);
};
const handleArchive = async () => {
const action = collection.isArchived ? 'unarchive' : 'archive';
if (confirm(`Are you sure you want to ${action} this collection?`)) {
try {
setActionLoading('archive');
await collectionApi.archiveCollection(collection.id, !collection.isArchived);
onUpdate();
} catch (error) {
console.error(`Failed to ${action} collection:`, error);
} finally {
setActionLoading(null);
}
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this collection? This cannot be undone. Stories will not be deleted.')) {
try {
setActionLoading('delete');
await collectionApi.deleteCollection(collection.id);
onDelete();
} catch (error) {
console.error('Failed to delete collection:', error);
} finally {
setActionLoading(null);
}
}
};
const startReading = () => {
if (collection.collectionStories && collection.collectionStories.length > 0) {
const firstStory = collection.collectionStories[0].story;
router.push(`/collections/${collection.id}/read/${firstStory.id}`);
}
};
return (
<div className="space-y-8">
{/* Header Section */}
<div className="theme-card p-6">
<div className="flex flex-col lg:flex-row gap-6">
{/* Cover Image */}
<div className="flex-shrink-0">
<div className="w-48 h-64 rounded-lg overflow-hidden bg-gray-100 mx-auto lg:mx-0">
{collection.coverImagePath ? (
<img
src={getImageUrl(collection.coverImagePath)}
alt={`${collection.name} cover`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
<div className="text-center p-4">
<div className="text-3xl font-bold theme-text mb-2">
{collection.storyCount}
</div>
<div className="text-sm theme-text opacity-60">
{collection.storyCount === 1 ? 'story' : 'stories'}
</div>
</div>
</div>
)}
</div>
</div>
{/* Collection Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-3">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="text-3xl font-bold theme-header bg-transparent border-b-2 border-gray-300 focus:border-blue-500 focus:outline-none w-full"
/>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Add a description..."
rows={3}
className="w-full theme-text bg-transparent border border-gray-300 rounded-lg p-2 focus:border-blue-500 focus:outline-none resize-none"
/>
<div className="flex items-center gap-4">
<label className="text-sm font-medium theme-text">Rating:</label>
<select
value={editRating}
onChange={(e) => setEditRating(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded theme-text focus:border-blue-500 focus:outline-none"
>
<option value="">No rating</option>
{[1, 2, 3, 4, 5].map(num => (
<option key={num} value={num}>{num} star{num > 1 ? 's' : ''}</option>
))}
</select>
</div>
</div>
) : (
<div>
<h1 className="text-3xl font-bold theme-header mb-2 break-words">
{collection.name}
{collection.isArchived && (
<span className="ml-3 inline-block bg-yellow-500 text-white px-3 py-1 rounded text-sm font-normal">
Archived
</span>
)}
</h1>
{collection.description && (
<p className="theme-text text-lg mb-4 whitespace-pre-wrap">
{collection.description}
</p>
)}
{collection.rating && (
<div className="mb-4">
{renderRatingStars(collection.rating)}
</div>
)}
</div>
)}
</div>
{/* Edit Controls */}
<div className="flex gap-2 ml-4">
{isEditing ? (
<>
<Button
size="sm"
onClick={handleSaveEdits}
disabled={saving || !editName.trim()}
>
{saving ? <LoadingSpinner size="sm" /> : 'Save'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCancelEdit}
disabled={saving}
>
Cancel
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(true)}
>
Edit
</Button>
)}
</div>
</div>
{/* Statistics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="text-center">
<div className="text-2xl font-bold theme-text">
{collection.storyCount}
</div>
<div className="text-sm theme-text opacity-60">
{collection.storyCount === 1 ? 'Story' : 'Stories'}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold theme-text">
{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'}
</div>
<div className="text-sm theme-text opacity-60">Reading Time</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold theme-text">
{collection.totalWordCount ? collection.totalWordCount.toLocaleString() : '0'}
</div>
<div className="text-sm theme-text opacity-60">Total Words</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold theme-text">
{collection.averageStoryRating && collection.averageStoryRating > 0 ? collection.averageStoryRating.toFixed(1) : '—'}
</div>
<div className="text-sm theme-text opacity-60">Avg Rating</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
<Button
onClick={startReading}
disabled={collection.storyCount === 0}
className="flex-shrink-0"
>
Read Collection
</Button>
<Button
variant="ghost"
onClick={() => router.push(`/collections/${collection.id}/edit`)}
className="flex-shrink-0"
>
Edit Collection
</Button>
<Button
variant="ghost"
onClick={() => setShowAddStories(true)}
className="flex-shrink-0"
>
Add Stories
</Button>
<Button
variant="ghost"
onClick={handleArchive}
disabled={actionLoading === 'archive'}
className="flex-shrink-0"
>
{actionLoading === 'archive' ? <LoadingSpinner size="sm" /> : (collection.isArchived ? 'Unarchive' : 'Archive')}
</Button>
<Button
variant="ghost"
onClick={handleDelete}
disabled={actionLoading === 'delete'}
className="flex-shrink-0 text-red-600 hover:text-red-800"
>
{actionLoading === 'delete' ? <LoadingSpinner size="sm" /> : 'Delete'}
</Button>
</div>
{/* Tags */}
{collection.tags && collection.tags.length > 0 && (
<div className="mt-6">
<h3 className="text-sm font-medium theme-text mb-2">Tags:</h3>
<div className="flex flex-wrap gap-2">
{collection.tags.map((tag) => (
<span
key={tag.id}
className="inline-block px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
>
{tag.name}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Stories Section */}
<div className="theme-card p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold theme-header">
Stories ({collection.storyCount})
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddStories(true)}
>
Add Stories
</Button>
</div>
<StoryReorderList
collection={collection}
onUpdate={onUpdate}
/>
</div>
{/* Add Stories Modal */}
<AddToCollectionModal
isOpen={showAddStories}
onClose={() => setShowAddStories(false)}
collection={collection}
onUpdate={onUpdate}
/>
</div>
);
}

View File

@@ -0,0 +1,415 @@
'use client';
import { useState, useEffect } from 'react';
import { searchApi, tagApi } from '../../lib/api';
import { Story, Tag } from '../../types/api';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface CollectionFormProps {
initialData?: {
name: string;
description?: string;
tags?: string[];
storyIds?: string[];
coverImagePath?: string;
};
onSubmit: (data: {
name: string;
description?: string;
tags?: string[];
storyIds?: string[];
coverImage?: File;
}) => Promise<void>;
onCancel: () => void;
loading?: boolean;
submitLabel?: string;
}
export default function CollectionForm({
initialData,
onSubmit,
onCancel,
loading = false,
submitLabel = 'Save Collection'
}: CollectionFormProps) {
const [name, setName] = useState(initialData?.name || '');
const [description, setDescription] = useState(initialData?.description || '');
const [tagInput, setTagInput] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>(initialData?.tags || []);
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
const [selectedStoryIds, setSelectedStoryIds] = useState<string[]>(initialData?.storyIds || []);
const [coverImage, setCoverImage] = useState<File | null>(null);
const [coverImagePreview, setCoverImagePreview] = useState<string | null>(null);
// Story selection state
const [storySearchQuery, setStorySearchQuery] = useState('');
const [availableStories, setAvailableStories] = useState<Story[]>([]);
const [selectedStories, setSelectedStories] = useState<Story[]>([]);
const [loadingStories, setLoadingStories] = useState(false);
const [showStorySelection, setShowStorySelection] = useState(false);
// Load tag suggestions when typing
useEffect(() => {
if (tagInput.length > 1) {
const loadSuggestions = async () => {
try {
const suggestions = await tagApi.getTagAutocomplete(tagInput);
setTagSuggestions(suggestions.filter(tag => !selectedTags.includes(tag)));
} catch (error) {
console.error('Failed to load tag suggestions:', error);
}
};
const debounceTimer = setTimeout(loadSuggestions, 300);
return () => clearTimeout(debounceTimer);
} else {
setTagSuggestions([]);
}
}, [tagInput, selectedTags]);
// Load stories for selection
useEffect(() => {
if (showStorySelection) {
const loadStories = async () => {
try {
setLoadingStories(true);
const result = await searchApi.search({
query: storySearchQuery || '*',
page: 0,
size: 50,
});
setAvailableStories(result.results || []);
} catch (error) {
console.error('Failed to load stories:', error);
} finally {
setLoadingStories(false);
}
};
const debounceTimer = setTimeout(loadStories, 300);
return () => clearTimeout(debounceTimer);
}
}, [storySearchQuery, showStorySelection]);
// Load selected stories data on mount
useEffect(() => {
if (selectedStoryIds.length > 0) {
const loadSelectedStories = async () => {
try {
const result = await searchApi.search({
query: '*',
page: 0,
size: 100,
});
const stories = result.results.filter(story => selectedStoryIds.includes(story.id));
setSelectedStories(stories);
} catch (error) {
console.error('Failed to load selected stories:', error);
}
};
loadSelectedStories();
}
}, [selectedStoryIds]);
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && tagInput.trim()) {
e.preventDefault();
const newTag = tagInput.trim();
if (!selectedTags.includes(newTag)) {
setSelectedTags(prev => [...prev, newTag]);
}
setTagInput('');
setTagSuggestions([]);
}
};
const addTag = (tag: string) => {
if (!selectedTags.includes(tag)) {
setSelectedTags(prev => [...prev, tag]);
}
setTagInput('');
setTagSuggestions([]);
};
const removeTag = (tagToRemove: string) => {
setSelectedTags(prev => prev.filter(tag => tag !== tagToRemove));
};
const handleCoverImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setCoverImage(file);
const reader = new FileReader();
reader.onload = (e) => {
setCoverImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const toggleStorySelection = (story: Story) => {
const isSelected = selectedStoryIds.includes(story.id);
if (isSelected) {
setSelectedStoryIds(prev => prev.filter(id => id !== story.id));
setSelectedStories(prev => prev.filter(s => s.id !== story.id));
} else {
setSelectedStoryIds(prev => [...prev, story.id]);
setSelectedStories(prev => [...prev, story]);
}
};
const removeSelectedStory = (storyId: string) => {
setSelectedStoryIds(prev => prev.filter(id => id !== storyId));
setSelectedStories(prev => prev.filter(s => s.id !== storyId));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
return;
}
await onSubmit({
name: name.trim(),
description: description.trim() || undefined,
tags: selectedTags,
storyIds: selectedStoryIds,
coverImage: coverImage || undefined,
});
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<div className="theme-card p-6">
<h2 className="text-lg font-semibold theme-header mb-4">Basic Information</h2>
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium theme-text mb-1">
Collection Name *
</label>
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter collection name"
required
className="w-full"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium theme-text mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe this collection (optional)"
rows={3}
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
{/* Cover Image Upload */}
<div>
<label htmlFor="coverImage" className="block text-sm font-medium theme-text mb-1">
Cover Image
</label>
<div className="flex items-start gap-4">
<div className="flex-1">
<input
id="coverImage"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleCoverImageChange}
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
<p className="text-xs theme-text opacity-60 mt-1">
JPG, PNG, or WebP. Max 800x1200px.
</p>
</div>
{(coverImagePreview || initialData?.coverImagePath) && (
<div className="w-20 h-24 rounded overflow-hidden bg-gray-100">
<img
src={coverImagePreview || (initialData?.coverImagePath ? `/images/${initialData.coverImagePath}` : '')}
alt="Cover preview"
className="w-full h-full object-cover"
/>
</div>
)}
</div>
</div>
</div>
</div>
{/* Tags */}
<div className="theme-card p-6">
<h2 className="text-lg font-semibold theme-header mb-4">Tags</h2>
<div className="space-y-3">
<div className="relative">
<Input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
placeholder="Type tags and press Enter"
className="w-full"
/>
{tagSuggestions.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border theme-border rounded-lg shadow-lg max-h-32 overflow-y-auto">
{tagSuggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => addTag(suggestion)}
className="w-full px-3 py-2 text-left hover:bg-gray-100 theme-text"
>
{suggestion}
</button>
))}
</div>
)}
</div>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-2 hover:text-red-200"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
{/* Story Selection */}
<div className="theme-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold theme-header">Stories</h2>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowStorySelection(!showStorySelection)}
>
{showStorySelection ? 'Hide' : 'Add'} Stories
</Button>
</div>
{/* Selected Stories */}
{selectedStories.length > 0 && (
<div className="mb-4">
<h3 className="text-sm font-medium theme-text mb-2">
Selected Stories ({selectedStories.length})
</h3>
<div className="space-y-2 max-h-32 overflow-y-auto">
{selectedStories.map((story) => (
<div key={story.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium theme-text truncate">{story.title}</p>
<p className="text-xs theme-text opacity-60">{story.authorName}</p>
</div>
<button
type="button"
onClick={() => removeSelectedStory(story.id)}
className="ml-2 text-red-600 hover:text-red-800"
>
×
</button>
</div>
))}
</div>
</div>
)}
{/* Story Selection Interface */}
{showStorySelection && (
<div className="space-y-3">
<Input
type="search"
value={storySearchQuery}
onChange={(e) => setStorySearchQuery(e.target.value)}
placeholder="Search stories to add..."
className="w-full"
/>
{loadingStories ? (
<div className="flex justify-center py-4">
<LoadingSpinner size="sm" />
</div>
) : (
<div className="max-h-64 overflow-y-auto space-y-2">
{availableStories.map((story) => {
const isSelected = selectedStoryIds.includes(story.id);
return (
<div
key={story.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
isSelected
? 'border-blue-500 bg-blue-50'
: 'theme-border hover:border-gray-400'
}`}
onClick={() => toggleStorySelection(story)}
>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStorySelection(story)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="font-medium theme-text truncate">{story.title}</p>
<p className="text-sm theme-text opacity-60">{story.authorName}</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Form Actions */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !name.trim()}
>
{loading ? <LoadingSpinner size="sm" /> : submitLabel}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { Collection } from '../../types/api';
import CollectionCard from './CollectionCard';
interface CollectionGridProps {
collections: Collection[];
viewMode: 'grid' | 'list';
onUpdate?: () => void;
}
export default function CollectionGrid({ collections, viewMode, onUpdate }: CollectionGridProps) {
if (collections.length === 0) {
return (
<div className="text-center py-20">
<div className="theme-text text-lg mb-4">
No collections found
</div>
<p className="theme-text opacity-70">
Create your first collection to organize your stories
</p>
</div>
);
}
return (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
: 'space-y-4'
}>
{collections.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
viewMode={viewMode}
onUpdate={onUpdate}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { StoryWithCollectionContext } from '../../types/api';
import Button from '../ui/Button';
import Link from 'next/link';
interface CollectionReadingViewProps {
data: StoryWithCollectionContext;
onNavigate: (storyId: string) => void;
onBackToCollection: () => void;
}
export default function CollectionReadingView({
data,
onNavigate,
onBackToCollection
}: CollectionReadingViewProps) {
const { story, collection } = data;
const handlePrevious = () => {
if (collection.previousStoryId) {
onNavigate(collection.previousStoryId);
}
};
const handleNext = () => {
if (collection.nextStoryId) {
onNavigate(collection.nextStoryId);
}
};
const renderRatingStars = (rating?: number) => {
if (!rating) return null;
return (
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-sm ${
star <= rating ? 'text-yellow-400' : 'text-gray-300'
}`}
>
</span>
))}
</div>
);
};
return (
<div className="max-w-4xl mx-auto">
{/* Collection Context Header */}
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={onBackToCollection}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
title="Back to Collection"
>
Back
</button>
<div>
<h2 className="font-semibold text-blue-900 dark:text-blue-100">
Reading from: {collection.name}
</h2>
<p className="text-sm text-blue-700 dark:text-blue-300">
Story {collection.currentPosition} of {collection.totalStories}
</p>
</div>
</div>
{/* Progress Bar */}
<div className="flex items-center gap-4">
<div className="w-32 bg-blue-200 dark:bg-blue-800 rounded-full h-2">
<div
className="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all duration-300"
style={{
width: `${(collection.currentPosition / collection.totalStories) * 100}%`
}}
/>
</div>
<span className="text-sm text-blue-700 dark:text-blue-300 font-mono">
{collection.currentPosition}/{collection.totalStories}
</span>
</div>
</div>
</div>
{/* Story Header */}
<div className="theme-card p-6 mb-6">
<div className="flex flex-col md:flex-row gap-6">
{/* Story Cover */}
{story.coverPath && (
<div className="flex-shrink-0">
<img
src={`/images/${story.coverPath}`}
alt={`${story.title} cover`}
className="w-32 h-40 object-cover rounded-lg mx-auto md:mx-0"
/>
</div>
)}
{/* Story Info */}
<div className="flex-1">
<h1 className="text-3xl font-bold theme-header mb-2">
{story.title}
</h1>
<div className="flex flex-wrap items-center gap-4 mb-4 text-sm theme-text opacity-70">
<Link
href={`/stories/${story.id}`}
className="hover:underline"
>
by {story.authorName}
</Link>
<span>{story.wordCount?.toLocaleString()} words</span>
{story.rating && (
<div className="flex items-center gap-1">
{renderRatingStars(story.rating)}
</div>
)}
{story.seriesName && (
<span>
{story.seriesName}
{story.volume && ` #${story.volume}`}
</span>
)}
</div>
{story.summary && (
<p className="theme-text mb-4 italic">
{story.summary}
</p>
)}
{/* Tags */}
{story.tags && story.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{story.tags.map((tag) => (
<span
key={tag.id}
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
>
{tag.name}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* Navigation Controls */}
<div className="flex justify-between items-center mb-6">
<Button
variant="ghost"
onClick={handlePrevious}
disabled={!collection.previousStoryId}
className="flex items-center gap-2"
>
Previous Story
</Button>
<div className="text-sm theme-text opacity-70">
Story {collection.currentPosition} of {collection.totalStories}
</div>
<Button
variant="ghost"
onClick={handleNext}
disabled={!collection.nextStoryId}
className="flex items-center gap-2"
>
Next Story
</Button>
</div>
{/* Story Content */}
<div className="theme-card p-8">
<div
className="prose prose-lg max-w-none theme-text"
dangerouslySetInnerHTML={{ __html: story.contentHtml }}
/>
</div>
{/* Bottom Navigation */}
<div className="flex justify-between items-center mt-8 p-4 theme-card">
<Button
variant="ghost"
onClick={handlePrevious}
disabled={!collection.previousStoryId}
className="flex items-center gap-2"
>
Previous
</Button>
<Button
variant="ghost"
onClick={onBackToCollection}
className="flex items-center gap-2"
>
Back to Collection
</Button>
<Button
variant="ghost"
onClick={handleNext}
disabled={!collection.nextStoryId}
className="flex items-center gap-2"
>
Next
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState, useEffect } from 'react';
import { Collection, Story } from '../../types/api';
import { collectionApi, getImageUrl } from '../../lib/api';
import Link from 'next/link';
import Button from '../ui/Button';
interface StoryReorderListProps {
collection: Collection;
onUpdate: () => void;
}
export default function StoryReorderList({ collection, onUpdate }: StoryReorderListProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [reordering, setReordering] = useState(false);
const [localStories, setLocalStories] = useState(collection.collectionStories || []);
// Update local stories when collection changes
useEffect(() => {
setLocalStories(collection.collectionStories || []);
}, [collection.collectionStories]);
const stories = localStories;
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', '');
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = async (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex || reordering) {
return;
}
// Optimistic update - update local state immediately
const newStories = [...stories];
const [draggedStory] = newStories.splice(draggedIndex, 1);
newStories.splice(dropIndex, 0, draggedStory);
setLocalStories(newStories);
try {
setReordering(true);
// Create reorder request with new positions
const storyOrders = newStories.map((storyItem, index) => ({
storyId: storyItem.story.id,
position: index + 1,
}));
await collectionApi.reorderStories(collection.id, storyOrders);
// Don't call onUpdate() to avoid page reload - the local state is already correct
} catch (error) {
console.error('Failed to reorder stories:', error);
// On error, refresh to get the correct order
onUpdate();
} finally {
setReordering(false);
setDraggedIndex(null);
}
};
const handleRemoveStory = async (storyId: string) => {
if (confirm('Remove this story from the collection?')) {
try {
await collectionApi.removeStoryFromCollection(collection.id, storyId);
onUpdate();
} catch (error) {
console.error('Failed to remove story:', error);
}
}
};
const moveStoryUp = async (index: number) => {
if (index === 0 || reordering) return;
// Optimistic update - update local state immediately
const newStories = [...stories];
[newStories[index - 1], newStories[index]] = [newStories[index], newStories[index - 1]];
setLocalStories(newStories);
try {
setReordering(true);
const storyOrders = newStories.map((storyItem, idx) => ({
storyId: storyItem.story.id,
position: idx + 1,
}));
await collectionApi.reorderStories(collection.id, storyOrders);
// Don't call onUpdate() to avoid page reload
} catch (error) {
console.error('Failed to reorder stories:', error);
// On error, refresh to get the correct order
onUpdate();
} finally {
setReordering(false);
}
};
const moveStoryDown = async (index: number) => {
if (index === stories.length - 1 || reordering) return;
// Optimistic update - update local state immediately
const newStories = [...stories];
[newStories[index], newStories[index + 1]] = [newStories[index + 1], newStories[index]];
setLocalStories(newStories);
try {
setReordering(true);
const storyOrders = newStories.map((storyItem, idx) => ({
storyId: storyItem.story.id,
position: idx + 1,
}));
await collectionApi.reorderStories(collection.id, storyOrders);
// Don't call onUpdate() to avoid page reload
} catch (error) {
console.error('Failed to reorder stories:', error);
// On error, refresh to get the correct order
onUpdate();
} finally {
setReordering(false);
}
};
if (stories.length === 0) {
return (
<div className="text-center py-8 theme-text opacity-70">
<p className="mb-4">No stories in this collection yet.</p>
<p className="text-sm">Add stories to start building your collection.</p>
</div>
);
}
return (
<div className="space-y-3">
{stories.map((storyItem, index) => {
const story = storyItem.story;
const isDragging = draggedIndex === index;
return (
<div
key={`${story.id}-${index}`}
draggable={!reordering}
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
className={`
flex items-center gap-4 p-4 theme-card rounded-lg border transition-all duration-200
${isDragging ? 'opacity-50 scale-95' : 'hover:border-gray-400'}
${reordering ? 'pointer-events-none' : 'cursor-move'}
`}
>
{/* Drag Handle */}
<div className="flex flex-col items-center text-gray-400 hover:text-gray-600">
<div className="text-xs font-mono bg-gray-100 px-2 py-1 rounded mb-1">
{index + 1}
</div>
<div className="flex flex-col gap-1">
<div className="w-3 h-1 bg-gray-300 rounded"></div>
<div className="w-3 h-1 bg-gray-300 rounded"></div>
<div className="w-3 h-1 bg-gray-300 rounded"></div>
</div>
</div>
{/* Story Cover */}
<div className="w-12 h-16 flex-shrink-0 rounded overflow-hidden bg-gray-100">
{story.coverPath ? (
<img
src={getImageUrl(story.coverPath)}
alt={`${story.title} cover`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
<span className="text-xs font-bold text-gray-600">
{story.title.charAt(0)}
</span>
</div>
)}
</div>
{/* Story Details */}
<div className="flex-1 min-w-0">
<Link
href={`/stories/${story.id}`}
className="block hover:underline"
>
<h3 className="font-medium theme-header truncate">
{story.title}
</h3>
</Link>
<p className="text-sm theme-text opacity-70 truncate">
by {story.authorName}
</p>
<div className="flex items-center gap-4 mt-1 text-xs theme-text opacity-60">
<span>{story.wordCount?.toLocaleString()} words</span>
{story.rating && (
<span className="flex items-center">
{story.rating}
</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-1">
<button
onClick={() => moveStoryUp(index)}
disabled={index === 0 || reordering}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
</button>
<button
onClick={() => moveStoryDown(index)}
disabled={index === stories.length - 1 || reordering}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
</button>
</div>
{/* Remove Button */}
<button
onClick={() => handleRemoveStory(story.id)}
disabled={reordering}
className="p-2 text-red-500 hover:text-red-700 disabled:opacity-30 disabled:cursor-not-allowed"
title="Remove from collection"
>
×
</button>
</div>
);
})}
{reordering && (
<div className="text-center py-4 theme-text opacity-70">
<span className="inline-flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
Reordering stories...
</span>
</div>
)}
</div>
);
}

View File

@@ -45,6 +45,12 @@ export default function Header() {
> >
Library Library
</Link> </Link>
<Link
href="/collections"
className="theme-text hover:theme-accent transition-colors font-medium"
>
Collections
</Link>
<Link <Link
href="/authors" href="/authors"
className="theme-text hover:theme-accent transition-colors font-medium" className="theme-text hover:theme-accent transition-colors font-medium"
@@ -111,6 +117,13 @@ export default function Header() {
> >
Library Library
</Link> </Link>
<Link
href="/collections"
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
onClick={() => setIsMenuOpen(false)}
>
Collections
</Link>
<Link <Link
href="/authors" href="/authors"
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1" className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"

View File

@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Textarea } from '../ui/Input'; import { Textarea } from '../ui/Input';
import Button from '../ui/Button'; import Button from '../ui/Button';
import { sanitizeHtmlSync, preloadSanitizationConfig } from '../../lib/sanitization'; import { sanitizeHtmlSync } from '../../lib/sanitization';
interface RichTextEditorProps { interface RichTextEditorProps {
value: string; value: string;
@@ -22,43 +22,62 @@ export default function RichTextEditor({
const [htmlValue, setHtmlValue] = useState(value); const [htmlValue, setHtmlValue] = useState(value);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const visualTextareaRef = useRef<HTMLTextAreaElement>(null); const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
const visualDivRef = useRef<HTMLDivElement>(null);
// Preload sanitization config // Preload sanitization config
useEffect(() => { useEffect(() => {
preloadSanitizationConfig().catch(console.error); // Clear cache and reload config to get latest sanitization rules
import('../../lib/sanitization').then(({ clearSanitizationCache, preloadSanitizationConfig }) => {
clearSanitizationCache();
preloadSanitizationConfig().catch(console.error);
});
}, []); }, []);
const handleVisualChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const plainText = e.target.value;
// Convert plain text to basic HTML paragraphs
const htmlContent = plainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
onChange(htmlContent); const handleVisualContentChange = () => {
setHtmlValue(htmlContent); const div = visualDivRef.current;
if (div) {
const newHtml = div.innerHTML;
onChange(newHtml);
setHtmlValue(newHtml);
}
}; };
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => { const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
if (viewMode !== 'visual') return; if (viewMode !== 'visual') return;
e.preventDefault(); e.preventDefault();
try { try {
// Try to get HTML content from clipboard // Try multiple approaches to get clipboard data
const items = e.clipboardData?.items; const clipboardData = e.clipboardData;
let htmlContent = ''; let htmlContent = '';
let plainText = ''; let plainText = '';
if (items) { // Method 1: Try direct getData calls first (more reliable)
for (const item of Array.from(items)) { try {
if (item.type === 'text/html') { htmlContent = clipboardData.getData('text/html');
plainText = clipboardData.getData('text/plain');
console.log('Paste debug - Direct method:');
console.log('HTML length:', htmlContent.length);
console.log('HTML preview:', htmlContent.substring(0, 200));
console.log('Plain text length:', plainText.length);
} catch (e) {
console.log('Direct getData failed:', e);
}
// Method 2: If direct method didn't work, try items approach
if (!htmlContent && clipboardData?.items) {
console.log('Trying items approach...');
const items = Array.from(clipboardData.items);
console.log('Available clipboard types:', items.map(item => item.type));
for (const item of items) {
if (item.type === 'text/html' && !htmlContent) {
htmlContent = await new Promise<string>((resolve) => { htmlContent = await new Promise<string>((resolve) => {
item.getAsString(resolve); item.getAsString(resolve);
}); });
} else if (item.type === 'text/plain') { } else if (item.type === 'text/plain' && !plainText) {
plainText = await new Promise<string>((resolve) => { plainText = await new Promise<string>((resolve) => {
item.getAsString(resolve); item.getAsString(resolve);
}); });
@@ -66,33 +85,153 @@ export default function RichTextEditor({
} }
} }
// If we have HTML content, sanitize it and merge with current content console.log('Final clipboard data:');
if (htmlContent) { console.log('HTML content length:', htmlContent.length);
console.log('Plain text length:', plainText.length);
// Additional debugging for clipboard types and content
if (clipboardData?.types) {
console.log('Clipboard types available:', clipboardData.types);
for (const type of clipboardData.types) {
try {
const data = clipboardData.getData(type);
console.log(`Type "${type}" content length:`, data.length);
if (data.length > 0 && data.length < 1000) {
console.log(`Type "${type}" content:`, data);
}
} catch (e) {
console.log(`Failed to get data for type "${type}":`, e);
}
}
}
// Process HTML content if available
if (htmlContent && htmlContent.trim().length > 0) {
console.log('Processing HTML content...');
console.log('Raw HTML:', htmlContent.substring(0, 500));
const sanitizedHtml = sanitizeHtmlSync(htmlContent); const sanitizedHtml = sanitizeHtmlSync(htmlContent);
console.log('Sanitized HTML length:', sanitizedHtml.length);
console.log('Sanitized HTML preview:', sanitizedHtml.substring(0, 500));
// Simply append the sanitized HTML to current content // Check if sanitization removed too much content
// This approach maintains the HTML formatting while being simpler const ratio = sanitizedHtml.length / htmlContent.length;
const newHtmlValue = value + sanitizedHtml; console.log('Sanitization ratio (kept/original):', ratio.toFixed(3));
if (ratio < 0.1) {
console.warn('Sanitization removed >90% of content - this might be too aggressive');
}
onChange(newHtmlValue); // Insert HTML directly into contentEditable div or at cursor in textarea
setHtmlValue(newHtmlValue); const visualDiv = visualDivRef.current;
} else if (plainText) { const textarea = visualTextareaRef.current;
// For plain text, convert to paragraphs and append
const textAsHtml = plainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
const newHtmlValue = value + textAsHtml; if (visualDiv && viewMode === 'visual') {
onChange(newHtmlValue); // For contentEditable div, insert HTML at current selection
setHtmlValue(newHtmlValue); const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
// Create a temporary container to parse the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = sanitizedHtml;
// Create a document fragment to insert all nodes at once
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
// Insert the entire fragment at once to preserve order
range.insertNode(fragment);
// Move cursor to end of inserted content
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
} else {
// No selection, append to end
visualDiv.innerHTML += sanitizedHtml;
}
// Update the state
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
} else if (textarea) {
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentPlainText = getPlainText(value);
const beforeCursor = currentPlainText.substring(0, start);
const afterCursor = currentPlainText.substring(end);
const beforeHtml = beforeCursor ? beforeCursor.split('\n\n').filter(p => p.trim()).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('\n') : '';
const afterHtml = afterCursor ? afterCursor.split('\n\n').filter(p => p.trim()).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('\n') : '';
const newHtmlValue = beforeHtml + (beforeHtml ? '\n' : '') + sanitizedHtml + (afterHtml ? '\n' : '') + afterHtml;
onChange(newHtmlValue);
setHtmlValue(newHtmlValue);
}
} else if (plainText && plainText.trim().length > 0) {
console.log('Processing plain text content...');
// For plain text, insert directly into contentEditable div
const visualDiv = visualDivRef.current;
if (visualDiv) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
// Split plain text into paragraphs and insert as HTML
const paragraphs = plainText.split('\n\n').filter(p => p.trim());
const fragment = document.createDocumentFragment();
paragraphs.forEach((paragraph, index) => {
if (index > 0) {
// Add some spacing between paragraphs
fragment.appendChild(document.createElement('br'));
}
const p = document.createElement('p');
p.textContent = paragraph.replace(/\n/g, ' ');
fragment.appendChild(p);
});
range.insertNode(fragment);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
} else {
// No selection, append to end
const textAsHtml = plainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
visualDiv.innerHTML += textAsHtml;
}
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
}
} else {
console.log('No usable clipboard content found');
} }
} catch (error) { } catch (error) {
console.error('Error handling paste:', error); console.error('Error handling paste:', error);
// Fallback to default paste behavior // Fallback to default paste behavior
const plainText = e.clipboardData.getData('text/plain'); const plainText = e.clipboardData.getData('text/plain');
handleVisualChange({ target: { value: plainText } } as React.ChangeEvent<HTMLTextAreaElement>); if (plainText) {
const textAsHtml = plainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
onChange(value + textAsHtml);
setHtmlValue(value + textAsHtml);
}
} }
}; };
@@ -114,26 +253,91 @@ export default function RichTextEditor({
const formatText = (tag: string) => { const formatText = (tag: string) => {
if (viewMode === 'visual') { if (viewMode === 'visual') {
// For visual mode, we'll just show formatting helpers const visualDiv = visualDivRef.current;
// In a real implementation, you'd want a proper WYSIWYG editor if (!visualDiv) return;
return;
}
const textarea = document.querySelector('textarea') as HTMLTextAreaElement; const selection = window.getSelection();
if (!textarea) return; if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const selectedText = range.toString();
const start = textarea.selectionStart; if (selectedText) {
const end = textarea.selectionEnd; // Wrap selected text in the formatting tag
const selectedText = htmlValue.substring(start, end); const formattedElement = document.createElement(tag);
formattedElement.textContent = selectedText;
if (selectedText) { range.deleteContents();
const beforeText = htmlValue.substring(0, start); range.insertNode(formattedElement);
const afterText = htmlValue.substring(end);
const formattedText = `<${tag}>${selectedText}</${tag}>`;
const newValue = beforeText + formattedText + afterText;
setHtmlValue(newValue); // Move cursor to end of inserted content
onChange(newValue); range.selectNodeContents(formattedElement);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
} else {
// No selection - insert template
const template = tag === 'h1' ? 'Heading 1' :
tag === 'h2' ? 'Heading 2' :
tag === 'h3' ? 'Heading 3' :
'Formatted text';
const formattedElement = document.createElement(tag);
formattedElement.textContent = template;
range.insertNode(formattedElement);
// Select the inserted text for easy editing
range.selectNodeContents(formattedElement);
selection.removeAllRanges();
selection.addRange(range);
}
// Update the state
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
}
} else {
// HTML mode - existing logic with improvements
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = htmlValue.substring(start, end);
if (selectedText) {
const beforeText = htmlValue.substring(0, start);
const afterText = htmlValue.substring(end);
const formattedText = `<${tag}>${selectedText}</${tag}>`;
const newValue = beforeText + formattedText + afterText;
setHtmlValue(newValue);
onChange(newValue);
// Restore cursor position
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start, start + formattedText.length);
}, 0);
} else {
// No selection - insert template at cursor
const template = tag === 'h1' ? '<h1>Heading 1</h1>' :
tag === 'h2' ? '<h2>Heading 2</h2>' :
tag === 'h3' ? '<h3>Heading 3</h3>' :
`<${tag}>Formatted text</${tag}>`;
const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start);
setHtmlValue(newValue);
onChange(newValue);
// Position cursor inside the new tag
setTimeout(() => {
const tagLength = `<${tag}>`.length;
const newPosition = start + tagLength;
textarea.focus();
textarea.setSelectionRange(newPosition, newPosition + (tag === 'p' ? 0 : template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length));
}, 0);
}
} }
}; };
@@ -162,50 +366,83 @@ export default function RichTextEditor({
</Button> </Button>
</div> </div>
{viewMode === 'html' && ( <div className="flex items-center gap-1">
<div className="flex items-center gap-1"> <Button
<Button type="button"
type="button" size="sm"
size="sm" variant="ghost"
variant="ghost" onClick={() => formatText('strong')}
onClick={() => formatText('strong')} title="Bold"
title="Bold" className="font-bold"
> >
<strong>B</strong> B
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => formatText('em')} onClick={() => formatText('em')}
title="Italic" title="Italic"
> className="italic"
<em>I</em> >
</Button> I
<Button </Button>
type="button" <div className="w-px h-4 bg-gray-300 mx-1" />
size="sm" <Button
variant="ghost" type="button"
onClick={() => formatText('p')} size="sm"
title="Paragraph" variant="ghost"
> onClick={() => formatText('h1')}
P title="Heading 1"
</Button> className="text-lg font-bold"
</div> >
)} H1
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h2')}
title="Heading 2"
className="text-base font-bold"
>
H2
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h3')}
title="Heading 3"
className="text-sm font-bold"
>
H3
</Button>
<div className="w-px h-4 bg-gray-300 mx-1" />
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('p')}
title="Paragraph"
>
P
</Button>
</div>
</div> </div>
{/* Editor */} {/* Editor */}
<div className="border theme-border rounded-b-lg overflow-hidden"> <div className="border theme-border rounded-b-lg overflow-hidden">
{viewMode === 'visual' ? ( {viewMode === 'visual' ? (
<Textarea <div
ref={visualTextareaRef} ref={visualDivRef}
value={getPlainText(value)} contentEditable
onChange={handleVisualChange} onInput={handleVisualContentChange}
onPaste={handlePaste} onPaste={handlePaste}
placeholder={placeholder} className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
rows={12} style={{ minHeight: '300px' }}
className="border-0 rounded-none focus:ring-0" dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
suppressContentEditableWarning={true}
/> />
) : ( ) : (
<Textarea <Textarea
@@ -236,11 +473,11 @@ export default function RichTextEditor({
<div className="text-xs theme-text"> <div className="text-xs theme-text">
<p> <p>
<strong>Visual mode:</strong> Write in plain text or paste formatted content. <strong>Visual mode:</strong> WYSIWYG editor - see your formatting as you type.
Bold, italic, and other basic formatting will be preserved when pasting. Paste formatted content from websites and it will preserve styling. Use toolbar buttons for formatting.
</p> </p>
<p> <p>
<strong>HTML mode:</strong> Write HTML directly for advanced formatting. <strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more. Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
</p> </p>
</div> </div>

View File

@@ -10,10 +10,20 @@ import Button from '../ui/Button';
interface StoryCardProps { interface StoryCardProps {
story: Story; story: Story;
viewMode: 'grid' | 'list'; viewMode: 'grid' | 'list';
onUpdate: () => void; onUpdate?: () => void;
showSelection?: boolean;
isSelected?: boolean;
onSelect?: () => void;
} }
export default function StoryCard({ story, viewMode, onUpdate }: StoryCardProps) { export default function StoryCard({
story,
viewMode,
onUpdate,
showSelection = false,
isSelected = false,
onSelect
}: StoryCardProps) {
const [rating, setRating] = useState(story.rating || 0); const [rating, setRating] = useState(story.rating || 0);
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
@@ -24,7 +34,7 @@ export default function StoryCard({ story, viewMode, onUpdate }: StoryCardProps)
setUpdating(true); setUpdating(true);
await storyApi.updateRating(story.id, newRating); await storyApi.updateRating(story.id, newRating);
setRating(newRating); setRating(newRating);
onUpdate(); onUpdate?.();
} catch (error) { } catch (error) {
console.error('Failed to update rating:', error); console.error('Failed to update rating:', error);
} finally { } finally {

View File

@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { Story } from '../../types/api';
import StoryCard from './StoryCard';
import StorySelectionToolbar from './StorySelectionToolbar';
interface StoryMultiSelectProps {
stories: Story[];
viewMode: 'grid' | 'list';
onUpdate?: () => void;
allowMultiSelect?: boolean;
}
export default function StoryMultiSelect({
stories,
viewMode,
onUpdate,
allowMultiSelect = true
}: StoryMultiSelectProps) {
const [selectedStoryIds, setSelectedStoryIds] = useState<string[]>([]);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const handleStorySelect = (storyId: string) => {
setSelectedStoryIds(prev => {
if (prev.includes(storyId)) {
const newSelection = prev.filter(id => id !== storyId);
if (newSelection.length === 0) {
setIsSelectionMode(false);
}
return newSelection;
} else {
if (!isSelectionMode) {
setIsSelectionMode(true);
}
return [...prev, storyId];
}
});
};
const handleSelectAll = () => {
if (selectedStoryIds.length === stories.length) {
setSelectedStoryIds([]);
setIsSelectionMode(false);
} else {
setSelectedStoryIds(stories.map(story => story.id));
setIsSelectionMode(true);
}
};
const handleClearSelection = () => {
setSelectedStoryIds([]);
setIsSelectionMode(false);
};
const handleBatchOperation = (operation: string) => {
// This will trigger the appropriate action based on the operation
console.log(`Batch operation: ${operation} on stories:`, selectedStoryIds);
// After operation, clear selection
setSelectedStoryIds([]);
setIsSelectionMode(false);
onUpdate?.();
};
if (stories.length === 0) {
return (
<div className="text-center py-20">
<div className="theme-text text-lg mb-4">
No stories found
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Selection Toolbar */}
{allowMultiSelect && (
<StorySelectionToolbar
selectedCount={selectedStoryIds.length}
totalCount={stories.length}
isSelectionMode={isSelectionMode}
onSelectAll={handleSelectAll}
onClearSelection={handleClearSelection}
onBatchOperation={handleBatchOperation}
selectedStoryIds={selectedStoryIds}
/>
)}
{/* Stories Grid/List */}
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
: 'space-y-4'
}>
{stories.map((story) => (
<div key={story.id} className="relative">
{/* Selection Checkbox */}
{allowMultiSelect && (isSelectionMode || selectedStoryIds.includes(story.id)) && (
<div className="absolute top-2 left-2 z-10">
<input
type="checkbox"
checked={selectedStoryIds.includes(story.id)}
onChange={() => handleStorySelect(story.id)}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 bg-white shadow-lg"
/>
</div>
)}
{/* Story Card */}
<div
className={`transition-all duration-200 ${
selectedStoryIds.includes(story.id) ? 'ring-2 ring-blue-500 ring-opacity-50' : ''
}`}
onDoubleClick={() => allowMultiSelect && handleStorySelect(story.id)}
>
<StoryCard
story={story}
viewMode={viewMode}
onUpdate={onUpdate}
showSelection={isSelectionMode}
isSelected={selectedStoryIds.includes(story.id)}
onSelect={() => handleStorySelect(story.id)}
/>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
'use client';
import { useState } from 'react';
import { collectionApi } from '../../lib/api';
import { Collection } from '../../types/api';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface StorySelectionToolbarProps {
selectedCount: number;
totalCount: number;
isSelectionMode: boolean;
onSelectAll: () => void;
onClearSelection: () => void;
onBatchOperation: (operation: string) => void;
selectedStoryIds: string[];
}
export default function StorySelectionToolbar({
selectedCount,
totalCount,
isSelectionMode,
onSelectAll,
onClearSelection,
onBatchOperation,
selectedStoryIds
}: StorySelectionToolbarProps) {
const [showAddToCollection, setShowAddToCollection] = useState(false);
const [collections, setCollections] = useState<Collection[]>([]);
const [loadingCollections, setLoadingCollections] = useState(false);
const [addingToCollection, setAddingToCollection] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [showCreateNew, setShowCreateNew] = useState(false);
const loadCollections = async () => {
try {
setLoadingCollections(true);
const result = await collectionApi.getCollections({
page: 0,
limit: 50,
archived: false,
});
setCollections(result.results || []);
} catch (error) {
console.error('Failed to load collections:', error);
} finally {
setLoadingCollections(false);
}
};
const handleShowAddToCollection = async () => {
setShowAddToCollection(true);
await loadCollections();
};
const handleAddToExistingCollection = async (collectionId: string) => {
try {
setAddingToCollection(true);
await collectionApi.addStoriesToCollection(collectionId, selectedStoryIds);
setShowAddToCollection(false);
onBatchOperation('addToCollection');
} catch (error) {
console.error('Failed to add stories to collection:', error);
} finally {
setAddingToCollection(false);
}
};
const handleCreateNewCollection = async () => {
if (!newCollectionName.trim()) return;
try {
setAddingToCollection(true);
const collection = await collectionApi.createCollection({
name: newCollectionName.trim(),
storyIds: selectedStoryIds,
});
setShowAddToCollection(false);
setNewCollectionName('');
setShowCreateNew(false);
onBatchOperation('createCollection');
} catch (error) {
console.error('Failed to create collection:', error);
} finally {
setAddingToCollection(false);
}
};
if (!isSelectionMode && selectedCount === 0) {
return null;
}
return (
<>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-center justify-between flex-wrap gap-4">
{/* Selection Info */}
<div className="flex items-center gap-4">
<span className="font-medium text-blue-900 dark:text-blue-100">
{selectedCount} of {totalCount} stories selected
</span>
<Button
variant="ghost"
size="sm"
onClick={selectedCount === totalCount ? onClearSelection : onSelectAll}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{selectedCount === totalCount ? 'Deselect All' : 'Select All'}
</Button>
{selectedCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearSelection}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Clear
</Button>
)}
</div>
{/* Batch Actions */}
{selectedCount > 0 && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleShowAddToCollection}
disabled={addingToCollection}
>
Add to Collection
</Button>
</div>
)}
</div>
</div>
{/* Add to Collection Modal */}
{showAddToCollection && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="theme-card max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b theme-border">
<h2 className="text-xl font-semibold theme-header">
Add {selectedCount} Stories to Collection
</h2>
<button
onClick={() => {
setShowAddToCollection(false);
setShowCreateNew(false);
setNewCollectionName('');
}}
disabled={addingToCollection}
className="text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
×
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loadingCollections ? (
<div className="flex justify-center py-8">
<LoadingSpinner size="md" />
</div>
) : (
<div className="space-y-4">
{/* Create New Collection */}
<div className="space-y-3">
<button
onClick={() => setShowCreateNew(!showCreateNew)}
className="w-full p-3 border-2 border-dashed theme-border rounded-lg theme-text hover:border-gray-400 transition-colors"
>
+ Create New Collection
</button>
{showCreateNew && (
<div className="space-y-3">
<input
type="text"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
placeholder="Enter collection name"
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateNewCollection();
}
}}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreateNewCollection}
disabled={!newCollectionName.trim() || addingToCollection}
>
{addingToCollection ? <LoadingSpinner size="sm" /> : 'Create'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreateNew(false);
setNewCollectionName('');
}}
disabled={addingToCollection}
>
Cancel
</Button>
</div>
</div>
)}
</div>
{/* Existing Collections */}
{collections.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium theme-text">Add to Existing Collection:</h3>
<div className="space-y-2 max-h-64 overflow-y-auto">
{collections.map((collection) => (
<button
key={collection.id}
onClick={() => handleAddToExistingCollection(collection.id)}
disabled={addingToCollection}
className="w-full p-3 text-left theme-card hover:border-gray-400 border theme-border rounded-lg transition-colors disabled:opacity-50"
>
<div className="font-medium theme-text">{collection.name}</div>
<div className="text-sm theme-text opacity-70">
{collection.storyCount} stories
</div>
</button>
))}
</div>
</div>
)}
{collections.length === 0 && !loadingCollections && (
<div className="text-center py-8 theme-text opacity-70">
No collections found. Create a new one above.
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -11,15 +11,17 @@ interface TagFilterProps {
export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilterProps) { export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilterProps) {
if (!Array.isArray(tags) || tags.length === 0) return null; if (!Array.isArray(tags) || tags.length === 0) return null;
// Sort tags by usage count (descending) and then alphabetically // Filter out tags with no stories, then sort by usage count (descending) and then alphabetically
const sortedTags = [...tags].sort((a, b) => { const sortedTags = [...tags]
const aCount = a.storyCount || 0; .filter(tag => (tag.storyCount || 0) > 0)
const bCount = b.storyCount || 0; .sort((a, b) => {
if (bCount !== aCount) { const aCount = a.storyCount || 0;
return bCount - aCount; const bCount = b.storyCount || 0;
} if (bCount !== aCount) {
return a.name.localeCompare(b.name); return bCount - aCount;
}); }
return a.name.localeCompare(b.name);
});
return ( return (
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -2,6 +2,7 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { authApi } from '../lib/api'; import { authApi } from '../lib/api';
import { preloadSanitizationConfig } from '../lib/sanitization';
interface AuthContextType { interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -30,7 +31,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}; };
// Preload sanitization config for content formatting
const loadSanitizationConfig = async () => {
try {
await preloadSanitizationConfig();
} catch (error) {
console.error('Failed to preload sanitization config:', error);
}
};
checkAuth(); checkAuth();
loadSanitizationConfig();
}, []); }, []);
const login = async (password: string) => { const login = async (password: string) => {

View File

@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult } from '../types/api'; import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
@@ -136,6 +136,11 @@ export const storyApi = {
return response.data; return response.data;
}, },
getStoryCollections: async (storyId: string): Promise<Collection[]> => {
const response = await api.get(`/stories/${storyId}/collections`);
return response.data;
},
reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => { reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
const response = await api.post('/stories/reindex-typesense'); const response = await api.post('/stories/reindex-typesense');
return response.data; return response.data;
@@ -268,7 +273,27 @@ export const searchApi = {
sortBy?: string; sortBy?: string;
sortDir?: string; sortDir?: string;
}): Promise<SearchResult> => { }): Promise<SearchResult> => {
const response = await api.get('/stories/search', { params }); // Create URLSearchParams to properly handle array parameters
const searchParams = new URLSearchParams();
// Add basic parameters
searchParams.append('query', params.query);
if (params.page !== undefined) searchParams.append('page', params.page.toString());
if (params.size !== undefined) searchParams.append('size', params.size.toString());
if (params.minRating !== undefined) searchParams.append('minRating', params.minRating.toString());
if (params.maxRating !== undefined) searchParams.append('maxRating', params.maxRating.toString());
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
if (params.sortDir) searchParams.append('sortDir', params.sortDir);
// Add array parameters - each element gets its own parameter
if (params.authors && params.authors.length > 0) {
params.authors.forEach(author => searchParams.append('authors', author));
}
if (params.tags && params.tags.length > 0) {
params.tags.forEach(tag => searchParams.append('tags', tag));
}
const response = await api.get(`/stories/search?${searchParams.toString()}`);
return response.data; return response.data;
}, },
}; };
@@ -287,6 +312,141 @@ export const configApi = {
}, },
}; };
// Collection endpoints
export const collectionApi = {
getCollections: async (params?: {
page?: number;
limit?: number;
search?: string;
tags?: string[];
archived?: boolean;
}): Promise<CollectionSearchResult> => {
// Create URLSearchParams to properly handle array parameters
const searchParams = new URLSearchParams();
if (params?.page !== undefined) searchParams.append('page', params.page.toString());
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString());
if (params?.search) searchParams.append('search', params.search);
if (params?.archived !== undefined) searchParams.append('archived', params.archived.toString());
// Add array parameters - each element gets its own parameter
if (params?.tags && params.tags.length > 0) {
params.tags.forEach(tag => searchParams.append('tags', tag));
}
const response = await api.get(`/collections?${searchParams.toString()}`);
return response.data;
},
getCollection: async (id: string): Promise<Collection> => {
const response = await api.get(`/collections/${id}`);
return response.data;
},
createCollection: async (collectionData: {
name: string;
description?: string;
tagNames?: string[];
storyIds?: string[];
}): Promise<Collection> => {
const response = await api.post('/collections', collectionData);
return response.data;
},
createCollectionWithImage: async (collectionData: {
name: string;
description?: string;
tags?: string[];
storyIds?: string[];
coverImage?: File;
}): Promise<Collection> => {
const formData = new FormData();
formData.append('name', collectionData.name);
if (collectionData.description) formData.append('description', collectionData.description);
if (collectionData.tags) {
collectionData.tags.forEach(tag => formData.append('tags', tag));
}
if (collectionData.storyIds) {
collectionData.storyIds.forEach(id => formData.append('storyIds', id));
}
if (collectionData.coverImage) {
formData.append('coverImage', collectionData.coverImage);
}
const response = await api.post('/collections', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
updateCollection: async (id: string, collectionData: {
name?: string;
description?: string;
tagNames?: string[];
rating?: number;
}): Promise<Collection> => {
const response = await api.put(`/collections/${id}`, collectionData);
return response.data;
},
deleteCollection: async (id: string): Promise<void> => {
await api.delete(`/collections/${id}`);
},
archiveCollection: async (id: string, archived: boolean): Promise<Collection> => {
const response = await api.put(`/collections/${id}/archive`, { archived });
return response.data;
},
addStoriesToCollection: async (id: string, storyIds: string[], position?: number): Promise<{
added: number;
skipped: number;
totalStories: number;
}> => {
const response = await api.post(`/collections/${id}/stories`, {
storyIds,
position,
});
return response.data;
},
removeStoryFromCollection: async (collectionId: string, storyId: string): Promise<void> => {
await api.delete(`/collections/${collectionId}/stories/${storyId}`);
},
reorderStories: async (collectionId: string, storyOrders: Array<{
storyId: string;
position: number;
}>): Promise<void> => {
await api.put(`/collections/${collectionId}/stories/order`, {
storyOrders,
});
},
getStoryWithCollectionContext: async (collectionId: string, storyId: string): Promise<StoryWithCollectionContext> => {
const response = await api.get(`/collections/${collectionId}/read/${storyId}`);
return response.data;
},
getCollectionStatistics: async (id: string): Promise<CollectionStatistics> => {
const response = await api.get(`/collections/${id}/stats`);
return response.data;
},
uploadCover: async (id: string, coverImage: File): Promise<{ imagePath: string }> => {
const formData = new FormData();
formData.append('file', coverImage);
const response = await api.post(`/collections/${id}/cover`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
removeCover: async (id: string): Promise<void> => {
await api.delete(`/collections/${id}/cover`);
},
};
// Image utility // Image utility
export const getImageUrl = (path: string): string => { export const getImageUrl = (path: string): string => {
if (!path) return ''; if (!path) return '';

View File

@@ -12,6 +12,38 @@ interface SanitizationConfig {
let cachedConfig: SanitizationConfig | null = null; let cachedConfig: SanitizationConfig | null = null;
let configPromise: Promise<SanitizationConfig> | null = null; let configPromise: Promise<SanitizationConfig> | null = null;
/**
* Filter CSS properties in a style attribute value
*/
function filterCssProperties(styleValue: string, allowedProperties: string[]): string {
// Parse CSS declarations
const declarations = styleValue.split(';').map(decl => decl.trim()).filter(decl => decl);
const filteredDeclarations = declarations.filter(declaration => {
const colonIndex = declaration.indexOf(':');
if (colonIndex === -1) return false;
const property = declaration.substring(0, colonIndex).trim().toLowerCase();
const isAllowed = allowedProperties.includes(property);
if (!isAllowed) {
console.log(`CSS property "${property}" was filtered out (not in allowed list)`);
}
return isAllowed;
});
const result = filteredDeclarations.join('; ');
if (declarations.length !== filteredDeclarations.length) {
console.log(`CSS filtering: ${declarations.length} -> ${filteredDeclarations.length} properties`);
console.log('Original:', styleValue);
console.log('Filtered:', result);
}
return result;
}
/** /**
* Fetch sanitization configuration from backend * Fetch sanitization configuration from backend
*/ */
@@ -94,18 +126,39 @@ function createDOMPurifyConfig(config: SanitizationConfig) {
}); });
} }
return { // Create a proper DOMPurify configuration
// DOMPurify expects ALLOWED_ATTR to be an array of allowed attributes
// We need to flatten the tag-specific attributes into a global list
const flattenedAttributes = Object.values(allowedAttributes).flat();
const uniqueAttributes = Array.from(new Set(flattenedAttributes));
const domPurifyConfig: DOMPurify.Config = {
ALLOWED_TAGS: allowedTags, ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: Object.keys(allowedAttributes).reduce((acc: string[], tag) => { ALLOWED_ATTR: uniqueAttributes,
return [...acc, ...allowedAttributes[tag]];
}, []),
// Allow style attribute but sanitize CSS properties
ALLOW_UNKNOWN_PROTOCOLS: false, ALLOW_UNKNOWN_PROTOCOLS: false,
SANITIZE_DOM: true, SANITIZE_DOM: true,
KEEP_CONTENT: true, KEEP_CONTENT: true,
// Custom hook to sanitize style attributes
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
} as DOMPurify.Config; };
// Clear any existing hooks and add CSS property filtering
DOMPurify.removeAllHooks();
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
// Only process elements with style attributes
if (node.hasAttribute && node.hasAttribute('style')) {
const styleValue = node.getAttribute('style');
if (styleValue) {
const filteredStyle = filterCssProperties(styleValue, config.allowedCssProperties);
if (filteredStyle) {
node.setAttribute('style', filteredStyle);
} else {
node.removeAttribute('style');
}
}
}
});
return domPurifyConfig;
} }
/** /**
@@ -133,21 +186,73 @@ export async function sanitizeHtml(html: string): Promise<string> {
/** /**
* Synchronous sanitization using cached config (for cases where async is not possible) * Synchronous sanitization using cached config (for cases where async is not possible)
* Falls back to basic DOMPurify if no config is cached * Falls back to a safe configuration if no config is cached
*/ */
export function sanitizeHtmlSync(html: string): string { export function sanitizeHtmlSync(html: string): string {
if (!html || html.trim() === '') { if (!html || html.trim() === '') {
return ''; return '';
} }
// If we have cached config, use it
if (cachedConfig) { if (cachedConfig) {
const domPurifyConfig = createDOMPurifyConfig(cachedConfig); const domPurifyConfig = createDOMPurifyConfig(cachedConfig);
return DOMPurify.sanitize(html, domPurifyConfig as any).toString(); return DOMPurify.sanitize(html, domPurifyConfig as any).toString();
} }
// Fallback to basic DOMPurify // If we don't have cached config but there's an ongoing request, wait for it
console.warn('No cached sanitization config available, using DOMPurify defaults'); if (configPromise) {
return DOMPurify.sanitize(html).toString(); console.log('Sanitization config loading in progress, using fallback for now');
} else {
// No config and no ongoing request - try to load it for next time
console.warn('No cached sanitization config available, triggering load for future use');
fetchSanitizationConfig().catch(error => {
console.error('Failed to load sanitization config:', error);
});
}
// Use comprehensive fallback configuration that preserves formatting
console.log('Using fallback sanitization configuration with formatting support');
const fallbackAllowedCssProperties = [
'color', 'font-size', 'font-weight',
'font-style', 'text-align', 'text-decoration', 'margin',
'padding', 'text-indent', 'line-height'
];
const fallbackConfig: DOMPurify.Config = {
ALLOWED_TAGS: [
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code', 'kbd', 'samp', 'var',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
'blockquote', 'cite', 'q', 'hr', 'details', 'summary'
],
ALLOWED_ATTR: [
'class', 'style', 'colspan', 'rowspan'
],
ALLOW_UNKNOWN_PROTOCOLS: false,
SANITIZE_DOM: true,
KEEP_CONTENT: true,
ALLOW_DATA_ATTR: false,
};
// Clear hooks and add CSS property filtering for fallback config
DOMPurify.removeAllHooks();
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if (node.hasAttribute && node.hasAttribute('style')) {
const styleValue = node.getAttribute('style');
if (styleValue) {
const filteredStyle = filterCssProperties(styleValue, fallbackAllowedCssProperties);
if (filteredStyle) {
node.setAttribute('style', filteredStyle);
} else {
node.removeAttribute('style');
}
}
}
});
return DOMPurify.sanitize(html, fallbackConfig as any).toString();
} }
/** /**

View File

@@ -76,3 +76,63 @@ export interface PagedResult<T> {
last: boolean; last: boolean;
empty: boolean; empty: boolean;
} }
export interface Collection {
id: string;
name: string;
description?: string;
rating?: number;
coverImagePath?: string;
isArchived: boolean;
tags: Tag[];
collectionStories?: CollectionStory[];
stories?: CollectionStory[]; // For compatibility
storyCount: number;
totalWordCount?: number;
estimatedReadingTime?: number;
averageStoryRating?: number;
createdAt: string;
updatedAt: string;
}
export interface CollectionStory {
story: Story;
position: number;
addedAt: string;
}
export interface CollectionSearchResult {
results: Collection[];
totalHits: number;
page: number;
perPage: number;
query: string;
searchTimeMs: number;
}
export interface CollectionReadingContext {
id: string;
name: string;
currentPosition: number;
totalStories: number;
previousStoryId?: string;
nextStoryId?: string;
}
export interface StoryWithCollectionContext {
story: Story;
collection: CollectionReadingContext;
}
export interface CollectionStatistics {
totalStories: number;
totalWordCount: number;
estimatedReadingTime: number;
averageStoryRating: number;
averageWordCount: number;
tagFrequency: Record<string, number>;
authorDistribution: Array<{
authorName: string;
storyCount: number;
}>;
}

File diff suppressed because one or more lines are too long

BIN
pinch-and-twist.epub Normal file

Binary file not shown.

View File

@@ -0,0 +1,642 @@
# StoryCove - Story Collections Feature Specification
## 1. Feature Overview
Story Collections allow users to organize stories into ordered lists for better content management and reading workflows. Collections support custom ordering, metadata, and provide an enhanced reading experience for grouped content.
### 1.1 Core Capabilities
- Create and manage ordered lists of stories
- Stories can belong to multiple collections
- Drag-and-drop reordering
- Collection-level metadata and ratings
- Dedicated collection reading flow
- Batch operations on collection contents
## 2. Data Model Updates
### 2.1 New Database Tables
#### Collections Table
```sql
CREATE TABLE collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(500) NOT NULL,
description TEXT,
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
cover_image_path VARCHAR(500),
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_collections_archived ON collections(is_archived);
```
#### Collection Stories Junction Table
```sql
CREATE TABLE collection_stories (
collection_id UUID NOT NULL,
story_id UUID NOT NULL,
position INTEGER NOT NULL,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (collection_id, story_id),
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE,
UNIQUE(collection_id, position)
);
CREATE INDEX idx_collection_stories_position ON collection_stories(collection_id, position);
```
#### Collection Tags Junction Table
```sql
CREATE TABLE collection_tags (
collection_id UUID NOT NULL,
tag_id UUID NOT NULL,
PRIMARY KEY (collection_id, tag_id),
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
```
### 2.2 Typesense Schema Update
Add new collection schema:
```json
{
"name": "collections",
"fields": [
{"name": "id", "type": "string"},
{"name": "name", "type": "string"},
{"name": "description", "type": "string", "optional": true},
{"name": "tags", "type": "string[]", "optional": true},
{"name": "story_count", "type": "int32"},
{"name": "total_word_count", "type": "int32"},
{"name": "rating", "type": "int32", "optional": true},
{"name": "is_archived", "type": "bool"},
{"name": "created_at", "type": "int64"},
{"name": "updated_at", "type": "int64"}
],
"default_sorting_field": "updated_at"
}
```
## 3. API Specification
### 3.1 Collection Endpoints
#### GET /api/collections
**IMPORTANT**: This endpoint MUST use Typesense for all search and filtering operations.
Do NOT implement search/filter logic using JPA/SQL queries.
Query parameters:
- `page` (integer): Page number
- `limit` (integer): Items per page
- `search` (string): Search in name and description (via Typesense)
- `tags` (string[]): Filter by tags (via Typesense)
- `archived` (boolean): Include archived collections (via Typesense filter)
Implementation note:
```java
// CORRECT: Use Typesense
return typesenseService.searchCollections(search, tags, archived, page, limit);
// INCORRECT: Do not use repository queries like this
// return collectionRepository.findByNameContainingAndTags(...);
```
Response includes:
- Collection metadata
- Story count
- Average story rating
- Total word count
- Estimated reading time
#### POST /api/collections
```json
Request (multipart/form-data):
{
"name": "string",
"description": "string",
"tags": ["string"],
"storyIds": ["uuid"], // Optional initial stories
"coverImage": "file (optional)"
}
Response:
{
"id": "uuid",
"name": "string",
"description": "string",
"tags": ["string"],
"storyCount": 0,
"averageStoryRating": null,
"rating": null,
"createdAt": "timestamp"
}
```
#### GET /api/collections/{id}
Returns full collection details with ordered story list
Response:
```json
{
"id": "uuid",
"name": "string",
"description": "string",
"tags": ["string"],
"rating": 1-5,
"coverImagePath": "string",
"storyCount": "integer",
"totalWordCount": "integer",
"estimatedReadingTime": "integer (minutes)",
"averageStoryRating": "float",
"stories": [
{
"id": "uuid",
"title": "string",
"author": "string",
"wordCount": "integer",
"rating": 1-5,
"coverImagePath": "string",
"position": "integer"
}
],
"createdAt": "timestamp",
"updatedAt": "timestamp"
}
```
#### PUT /api/collections/{id}
Update collection metadata (same structure as POST without storyIds)
#### DELETE /api/collections/{id}
Delete a collection (stories remain in the system)
#### PUT /api/collections/{id}/archive
Archive/unarchive a collection
```json
Request:
{
"archived": boolean
}
```
### 3.2 Collection Story Management
#### POST /api/collections/{id}/stories
Add stories to collection
```json
Request:
{
"storyIds": ["uuid"],
"position": "integer" // Optional, defaults to end
}
Response:
{
"added": 3,
"skipped": 1, // Already in collection
"totalStories": 15
}
```
#### PUT /api/collections/{id}/stories/order
Reorder stories in collection
```json
Request:
{
"storyOrders": [
{"storyId": "uuid", "position": 1},
{"storyId": "uuid", "position": 2}
]
}
```
#### DELETE /api/collections/{id}/stories/{storyId}
Remove a story from collection
#### GET /api/collections/{id}/read/{storyId}
Get story content with collection navigation context
```json
Response:
{
"story": { /* full story data */ },
"collection": {
"id": "uuid",
"name": "string",
"currentPosition": 3,
"totalStories": 10,
"previousStoryId": "uuid",
"nextStoryId": "uuid"
}
}
```
### 3.3 Batch Operations
#### POST /api/stories/batch/add-to-collection
Add multiple stories to a collection
```json
Request:
{
"storyIds": ["uuid"],
"collectionId": "uuid" // null to create new
"newCollectionName": "string" // if creating new
}
```
### 3.4 Collection Statistics
#### GET /api/collections/{id}/stats
Detailed statistics for a collection
```json
Response:
{
"totalStories": 15,
"totalWordCount": 125000,
"estimatedReadingTime": 625, // minutes
"averageStoryRating": 4.2,
"averageWordCount": 8333,
"tagFrequency": {
"fantasy": 12,
"adventure": 8
},
"authorDistribution": [
{"authorName": "string", "storyCount": 5}
]
}
```
## 4. UI/UX Specifications
### 4.1 Navigation Updates
Add "Collections" to the main navigation menu, same level as "Stories" and "Authors"
### 4.2 Collections Overview Page
- Grid/List view toggle
- Collection cards showing:
- Cover image (or first 4 story covers as mosaic)
- Name and description preview
- Story count and total reading time
- Rating stars
- Tag badges
- **Pagination controls** (page size selector, page navigation)
- Filter by tags
- Search collections
- "Create New Collection" button
- Archive toggle
**IMPORTANT**: This view MUST use pagination via Typesense. Do NOT load all collections at once.
Default page size: 20 collections per page (configurable: 10, 20, 50)
### 4.3 Collection Creation/Edit Modal
- Name input (required)
- Description textarea
- Tag input with autocomplete
- Cover image upload
- Initial story selection (optional):
- Search and filter stories
- Checkbox selection
- Selected stories preview
### 4.4 Collection Detail View
- Header section:
- Cover image or story mosaic
- Collection name (editable inline)
- Description (editable inline)
- Statistics bar: X stories • Y hours reading time • Average rating
- Action buttons: Read Collection, Edit, Export (Phase 2), Archive, Delete
- Story list section:
- Drag-and-drop reordering (drag handle on each row)
- Story cards with position number
- Remove from collection button
- Add stories button
- Rating section:
- Collection rating (manual)
- Average story rating (calculated)
### 4.5 Story List View Updates
- Multi-select mode:
- Checkbox appears on hover/in mobile
- Selection toolbar with "Add to Collection" button
- Create new or add to existing collection
### 4.6 Story Detail View Updates
- "Add to Collection" button in the action bar
- "Part of Collections" section showing which collections include this story
### 4.7 Reading View Updates
When reading from a collection:
- Collection name and progress (Story 3 of 15) in header
- Previous/Next navigation uses collection order
- "Back to Collection" button
- Progress bar showing position in collection
### 4.8 Responsive Design Considerations
- Mobile: Single column layout, bottom sheet for actions
- Tablet: Two-column layout for collection detail
- Desktop: Full drag-and-drop, hover states
## 5. Technical Implementation Details
### 5.1 Frontend Updates
#### State Management
```typescript
// Collection context for managing active collection during reading
interface CollectionReadingContext {
collectionId: string;
currentPosition: number;
totalStories: number;
stories: StoryPreview[];
}
// Drag and drop using @dnd-kit/sortable
interface DragEndEvent {
active: { id: string };
over: { id: string };
}
```
#### Components Structure
```
components/
collections/
CollectionCard.tsx
CollectionGrid.tsx
CollectionForm.tsx
CollectionDetailView.tsx
StoryReorderList.tsx
AddToCollectionModal.tsx
stories/
StoryMultiSelect.tsx
StorySelectionToolbar.tsx
```
#### Pagination Implementation
```typescript
// Collections overview MUST use pagination
interface CollectionsPageState {
page: number;
pageSize: number; // 10, 20, or 50
totalPages: number;
totalCollections: number;
}
// Fetch collections with pagination via Typesense
const fetchCollections = async (page: number, pageSize: number, filters: any) => {
// MUST use Typesense API with pagination params
const response = await api.get('/api/collections', {
params: {
page,
limit: pageSize,
...filters
}
});
return response.data;
};
// Component must handle:
// - Page navigation (previous/next, direct page input)
// - Page size selection
// - Maintaining filters/search across page changes
// - URL state sync for shareable/bookmarkable pages
```
### 5.2 Backend Updates
#### Service Layer
```java
@Service
public class CollectionService {
@Autowired
private TypesenseService typesenseService;
// IMPORTANT: All search and filtering MUST use Typesense, not JPA queries
public Page<Collection> searchCollections(String query, List<String> tags, boolean includeArchived) {
// Use typesenseService.searchCollections() - NOT repository queries
return typesenseService.searchCollections(query, tags, includeArchived);
}
// Calculate statistics dynamically
public CollectionStats calculateStats(UUID collectionId);
// Validate no duplicate stories
public void validateStoryAddition(UUID collectionId, List<UUID> storyIds);
// Reorder with gap-based positioning
public void reorderStories(UUID collectionId, List<StoryOrder> newOrder);
// Cascade position updates on removal
public void removeStoryAndReorder(UUID collectionId, UUID storyId);
}
```
#### Search Implementation Requirements
```java
// CRITICAL: All collection search operations MUST go through Typesense
// DO NOT implement search/filter logic in JPA repositories
@Service
public class TypesenseService {
// Existing methods for stories...
// New methods for collections
public SearchResult<Collection> searchCollections(
String query,
List<String> tags,
boolean includeArchived,
int page,
int limit
) {
// Build Typesense query
// Handle text search, tag filtering, archive status
// Return paginated results
}
public void indexCollection(Collection collection) {
// Index/update collection in Typesense
// Include calculated fields like story_count, total_word_count
}
public void removeCollection(UUID collectionId) {
// Remove from Typesense index
}
}
// Repository should ONLY be used for:
// - CRUD operations by ID
// - Relationship management
// - Position updates
// NOT for search, filtering, or listing with criteria
```
#### Position Management Strategy
Use gap-based positioning (multiples of 1000) to minimize reorder updates:
- Initial positions: 1000, 2000, 3000...
- Insert between: (prev + next) / 2
- Rebalance when gaps get too small
### 5.3 Performance Optimizations
1. **Lazy Loading**: Load collection stories in batches
2. **Caching**: Cache collection statistics for 5 minutes
3. **Batch Operations**: Multi-story operations in single transaction
4. **Optimistic Updates**: Immediate UI updates for reordering
### 5.4 Critical Implementation Guidelines
#### Search and Filtering Architecture
**MANDATORY**: All search, filtering, and listing operations MUST use Typesense as the primary data source.
```java
// Controller pattern for ALL list/search endpoints
@GetMapping("/api/collections")
public ResponseEntity<Page<CollectionDTO>> getCollections(
@RequestParam(required = false) String search,
@RequestParam(required = false) List<String> tags,
@RequestParam(defaultValue = "false") boolean archived,
Pageable pageable
) {
// MUST delegate to Typesense service
return ResponseEntity.ok(
typesenseService.searchCollections(search, tags, archived, pageable)
);
}
// Service layer MUST use Typesense
// NEVER implement search logic in repositories
// Repository pattern should ONLY be used for:
// 1. Direct ID lookups
// 2. Saving/updating entities
// 3. Managing relationships
// 4. NOT for searching, filtering, or conditional queries
```
#### Synchronization Strategy
1. On every collection create/update/delete → immediately sync with Typesense
2. Include denormalized data in Typesense documents (story count, word count, etc.)
3. Use database only as source of truth for relationships and detailed data
4. Use Typesense for all discovery operations (search, filter, list)
## 6. Migration and Upgrade Path
### 6.1 Database Migration
```sql
-- Run migrations in order
-- 1. Create new tables
-- 2. Add indexes
-- 3. No data migration needed (new feature)
```
### 6.2 Search Index Update
- Deploy new Typesense schema
- No reindexing needed for existing stories
## 7. Testing Requirements
### 7.1 Unit Tests
- Collection CRUD operations
- Position management logic
- Statistics calculation
- Duplicate prevention
### 7.2 Integration Tests
- Multi-story batch operations
- Drag-and-drop reordering
- Collection reading flow
- Search and filtering
### 7.3 E2E Tests
- Create collection from multiple entry points
- Complete reading flow through collection
- Reorder and verify persistence
## 8. Future Enhancements (Phase 2+)
1. **Collection Templates**: Pre-configured collection types
2. **Smart Collections**: Auto-populate based on criteria
3. **Collection Sharing**: Generate shareable links
4. **Reading Progress**: Track progress through collections
5. **Export Collections**: PDF/EPUB with proper ordering
6. **Collection Recommendations**: Based on reading patterns
7. **Nested Collections**: Collections within collections
8. **Collection Permissions**: For multi-user scenarios
## 9. Implementation Checklist
### Backend Tasks
- [ ] Create database migrations
- [ ] Implement entity models
- [ ] Create repository interfaces
- [ ] Implement service layer with business logic
- [ ] Create REST controllers
- [ ] Add validation and error handling
- [ ] Update Typesense sync logic
- [ ] Write unit and integration tests
### Frontend Tasks
- [ ] Update navigation structure
- [ ] Create collection components
- [ ] Implement drag-and-drop reordering
- [ ] Add multi-select to story list
- [ ] Update story detail view
- [ ] Implement collection reading flow
- [ ] Add loading and error states
- [ ] Write component tests
### Documentation Tasks
- [ ] Update API documentation
- [ ] Create user guide for collections
- [ ] Document position management strategy
- [ ] Add collection examples to README
## 10. Acceptance Criteria
1. Users can create, edit, and delete collections
2. Stories can be added/removed from collections without duplication
3. Collection order persists across sessions
4. Drag-and-drop reordering works smoothly
5. Collection statistics update in real-time
6. Reading flow respects collection order
7. **Search and filtering work for collections using Typesense (NOT database queries)**
8. All actions are validated and provide clear feedback
9. Performance remains smooth with large collections (100+ stories)
10. Mobile experience is fully functional
## 11. Implementation Anti-Patterns to Avoid
### CRITICAL: Search Implementation
The following patterns MUST NOT be used for search/filter operations:
```java
// ❌ WRONG - Do not use JPA/Repository for search
@Repository
public interface CollectionRepository extends JpaRepository<Collection, UUID> {
// DO NOT ADD THESE METHODS:
List<Collection> findByNameContaining(String name);
List<Collection> findByTagsIn(List<String> tags);
Page<Collection> findByNameContainingAndArchived(String name, boolean archived, Pageable pageable);
}
// ❌ WRONG - Do not implement search in service using repositories
public Page<Collection> searchCollections(String query) {
return collectionRepository.findByNameContaining(query);
}
// ✅ CORRECT - Always use Typesense for search/filter
public Page<Collection> searchCollections(String query, List<String> tags) {
SearchResult result = typesenseClient.collections("collections")
.documents()
.search(searchParameters);
return convertToPage(result);
}
```
### Remember:
1. **Typesense** = Search, filter, list, discover (with pagination)
2. **Database** = Store, retrieve by ID, manage relationships
3. **Never** mix search logic between the two systems
4. **Always** paginate list views - never load all items at once