Compare commits
13 Commits
77ad643eac
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e6236548d | |||
|
|
f068a6eb6f | ||
|
|
312093ae2e | ||
|
|
9dd8855914 | ||
|
|
6f478ab97a | ||
|
|
12a8f2ee27 | ||
|
|
a38812877a | ||
|
|
d48e217cbb | ||
|
|
aae6091ef4 | ||
|
|
131e2e8c25 | ||
|
|
90428894b4 | ||
|
|
a3f2801696 | ||
|
|
8580d660e9 |
BIN
0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg
Normal file
BIN
0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
263
README.md
263
README.md
@@ -103,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`.
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -40,23 +40,26 @@ public class StoryController {
|
|||||||
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,
|
||||||
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.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,
|
||||||
@@ -67,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);
|
||||||
}
|
}
|
||||||
@@ -206,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);
|
||||||
@@ -230,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 {
|
||||||
@@ -390,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());
|
||||||
@@ -399,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;
|
||||||
@@ -479,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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
106
backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java
Normal file
106
backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/src/main/java/com/storycove/dto/CollectionDto.java
Normal file
141
backend/src/main/java/com/storycove/dto/CollectionDto.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
backend/src/main/java/com/storycove/dto/StorySummaryDto.java
Normal file
172
backend/src/main/java/com/storycove/dto/StorySummaryDto.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
233
backend/src/main/java/com/storycove/entity/Collection.java
Normal file
233
backend/src/main/java/com/storycove/entity/Collection.java
Normal 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 +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
114
backend/src/main/java/com/storycove/entity/CollectionStory.java
Normal file
114
backend/src/main/java/com/storycove/entity/CollectionStory.java
Normal 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 +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -339,8 +340,10 @@ public class AuthorService {
|
|||||||
existing.setAuthorRating(updates.getAuthorRating());
|
existing.setAuthorRating(updates.getAuthorRating());
|
||||||
}
|
}
|
||||||
if (updates.getUrls() != null) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
1056
docs/API.md
Normal file
1056
docs/API.md
Normal file
File diff suppressed because it is too large
Load Diff
263
docs/DATA_MODEL.md
Normal file
263
docs/DATA_MODEL.md
Normal 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
147
docs/README.md
Normal 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
583
docs/openapi.yaml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
142
frontend/src/app/collections/[id]/edit/page.tsx
Normal file
142
frontend/src/app/collections/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/app/collections/[id]/page.tsx
Normal file
85
frontend/src/app/collections/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/app/collections/[id]/read/[storyId]/page.tsx
Normal file
82
frontend/src/app/collections/[id]/read/[storyId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/app/collections/new/page.tsx
Normal file
84
frontend/src/app/collections/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
frontend/src/app/collections/page.tsx
Normal file
286
frontend/src/app/collections/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
201
frontend/src/components/collections/AddToCollectionModal.tsx
Normal file
201
frontend/src/components/collections/AddToCollectionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/src/components/collections/CollectionCard.tsx
Normal file
203
frontend/src/components/collections/CollectionCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
frontend/src/components/collections/CollectionDetailView.tsx
Normal file
360
frontend/src/components/collections/CollectionDetailView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
415
frontend/src/components/collections/CollectionForm.tsx
Normal file
415
frontend/src/components/collections/CollectionForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/components/collections/CollectionGrid.tsx
Normal file
42
frontend/src/components/collections/CollectionGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
frontend/src/components/collections/CollectionReadingView.tsx
Normal file
218
frontend/src/components/collections/CollectionReadingView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
frontend/src/components/collections/StoryReorderList.tsx
Normal file
264
frontend/src/components/collections/StoryReorderList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
const handleVisualContentChange = () => {
|
||||||
// Convert plain text to basic HTML paragraphs
|
const div = visualDivRef.current;
|
||||||
const htmlContent = plainText
|
if (div) {
|
||||||
.split('\n\n')
|
const newHtml = div.innerHTML;
|
||||||
.filter(paragraph => paragraph.trim())
|
onChange(newHtml);
|
||||||
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
setHtmlValue(newHtml);
|
||||||
.join('\n');
|
}
|
||||||
|
|
||||||
onChange(htmlContent);
|
|
||||||
setHtmlValue(htmlContent);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
// Wrap selected text in the formatting tag
|
||||||
|
const formattedElement = document.createElement(tag);
|
||||||
|
formattedElement.textContent = selectedText;
|
||||||
|
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(formattedElement);
|
||||||
|
|
||||||
|
// Move cursor to end of inserted content
|
||||||
|
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 start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const selectedText = htmlValue.substring(start, end);
|
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);
|
if (selectedText) {
|
||||||
onChange(newValue);
|
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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
131
frontend/src/components/stories/StoryMultiSelect.tsx
Normal file
131
frontend/src/components/stories/StoryMultiSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
frontend/src/components/stories/StorySelectionToolbar.tsx
Normal file
251
frontend/src/components/stories/StorySelectionToolbar.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,4 +75,64 @@ export interface PagedResult<T> {
|
|||||||
first: boolean;
|
first: boolean;
|
||||||
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
BIN
pinch-and-twist.epub
Normal file
Binary file not shown.
642
storycove-collections-spec.md
Normal file
642
storycove-collections-spec.md
Normal 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
|
||||||
Reference in New Issue
Block a user