From 9dd88559146c0b6a5f9be7b774cf24b4479a20ff Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Fri, 25 Jul 2025 08:00:22 +0200 Subject: [PATCH 1/2] Specification --- storycove-collections-spec.md | 642 ++++++++++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 storycove-collections-spec.md diff --git a/storycove-collections-spec.md b/storycove-collections-spec.md new file mode 100644 index 0000000..59b1094 --- /dev/null +++ b/storycove-collections-spec.md @@ -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 searchCollections(String query, List 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 storyIds); + + // Reorder with gap-based positioning + public void reorderStories(UUID collectionId, List 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 searchCollections( + String query, + List 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> getCollections( + @RequestParam(required = false) String search, + @RequestParam(required = false) List 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 { + // DO NOT ADD THESE METHODS: + List findByNameContaining(String name); + List findByTagsIn(List tags); + Page findByNameContainingAndArchived(String name, boolean archived, Pageable pageable); +} + +// ❌ WRONG - Do not implement search in service using repositories +public Page searchCollections(String query) { + return collectionRepository.findByNameContaining(query); +} + +// ✅ CORRECT - Always use Typesense for search/filter +public Page searchCollections(String query, List 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 \ No newline at end of file -- 2.49.1 From 312093ae2e90dc2782e268c7039db5e94dd9e6fa Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Fri, 25 Jul 2025 14:15:23 +0200 Subject: [PATCH 2/2] Story Collections Feature --- ...5-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg | Bin 0 -> 37911 bytes .../controller/AuthorController.java | 34 +- .../controller/CollectionController.java | 421 +++++++++++++++++ .../storycove/controller/StoryController.java | 113 ++++- .../com/storycove/dto/AuthorSummaryDto.java | 106 +++++ .../java/com/storycove/dto/CollectionDto.java | 141 ++++++ .../com/storycove/dto/CollectionStoryDto.java | 46 ++ .../com/storycove/dto/StorySummaryDto.java | 172 +++++++ .../java/com/storycove/entity/Author.java | 2 + .../java/com/storycove/entity/Collection.java | 233 ++++++++++ .../com/storycove/entity/CollectionStory.java | 114 +++++ .../storycove/entity/CollectionStoryId.java | 61 +++ .../java/com/storycove/entity/Series.java | 2 + .../main/java/com/storycove/entity/Story.java | 5 + .../main/java/com/storycove/entity/Tag.java | 2 + .../repository/CollectionRepository.java | 48 ++ .../repository/CollectionStoryRepository.java | 93 ++++ .../service/CollectionSearchResult.java | 56 +++ .../storycove/service/CollectionService.java | 423 ++++++++++++++++++ .../storycove/service/TypesenseService.java | 294 +++++++++++- .../src/app/collections/[id]/edit/page.tsx | 142 ++++++ frontend/src/app/collections/[id]/page.tsx | 85 ++++ .../collections/[id]/read/[storyId]/page.tsx | 82 ++++ frontend/src/app/collections/new/page.tsx | 84 ++++ frontend/src/app/collections/page.tsx | 286 ++++++++++++ frontend/src/app/library/page.tsx | 22 +- frontend/src/app/stories/[id]/detail/page.tsx | 58 ++- .../collections/AddToCollectionModal.tsx | 201 +++++++++ .../components/collections/CollectionCard.tsx | 203 +++++++++ .../collections/CollectionDetailView.tsx | 360 +++++++++++++++ .../components/collections/CollectionForm.tsx | 415 +++++++++++++++++ .../components/collections/CollectionGrid.tsx | 42 ++ .../collections/CollectionReadingView.tsx | 218 +++++++++ .../collections/StoryReorderList.tsx | 264 +++++++++++ frontend/src/components/layout/Header.tsx | 13 + frontend/src/components/stories/StoryCard.tsx | 16 +- .../components/stories/StoryMultiSelect.tsx | 131 ++++++ .../stories/StorySelectionToolbar.tsx | 251 +++++++++++ frontend/src/lib/api.ts | 142 +++++- frontend/src/types/api.ts | 60 +++ frontend/tsconfig.tsbuildinfo | 2 +- pinch-and-twist.epub | Bin 0 -> 1291969 bytes 42 files changed, 5398 insertions(+), 45 deletions(-) create mode 100644 0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg create mode 100644 backend/src/main/java/com/storycove/controller/CollectionController.java create mode 100644 backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java create mode 100644 backend/src/main/java/com/storycove/dto/CollectionDto.java create mode 100644 backend/src/main/java/com/storycove/dto/CollectionStoryDto.java create mode 100644 backend/src/main/java/com/storycove/dto/StorySummaryDto.java create mode 100644 backend/src/main/java/com/storycove/entity/Collection.java create mode 100644 backend/src/main/java/com/storycove/entity/CollectionStory.java create mode 100644 backend/src/main/java/com/storycove/entity/CollectionStoryId.java create mode 100644 backend/src/main/java/com/storycove/repository/CollectionRepository.java create mode 100644 backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java create mode 100644 backend/src/main/java/com/storycove/service/CollectionSearchResult.java create mode 100644 backend/src/main/java/com/storycove/service/CollectionService.java create mode 100644 frontend/src/app/collections/[id]/edit/page.tsx create mode 100644 frontend/src/app/collections/[id]/page.tsx create mode 100644 frontend/src/app/collections/[id]/read/[storyId]/page.tsx create mode 100644 frontend/src/app/collections/new/page.tsx create mode 100644 frontend/src/app/collections/page.tsx create mode 100644 frontend/src/components/collections/AddToCollectionModal.tsx create mode 100644 frontend/src/components/collections/CollectionCard.tsx create mode 100644 frontend/src/components/collections/CollectionDetailView.tsx create mode 100644 frontend/src/components/collections/CollectionForm.tsx create mode 100644 frontend/src/components/collections/CollectionGrid.tsx create mode 100644 frontend/src/components/collections/CollectionReadingView.tsx create mode 100644 frontend/src/components/collections/StoryReorderList.tsx create mode 100644 frontend/src/components/stories/StoryMultiSelect.tsx create mode 100644 frontend/src/components/stories/StorySelectionToolbar.tsx create mode 100644 pinch-and-twist.epub diff --git a/0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg b/0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7e395d84a416fe975ec060a1ad826b9ae7107562 GIT binary patch literal 37911 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 beKfPVQQwd@u2)lfdFv2Q_> getAllAuthors( + public ResponseEntity> getAllAuthors( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "name") String sortBy, @@ -54,7 +52,7 @@ public class AuthorController { Pageable pageable = PageRequest.of(page, size, sort); Page authors = authorService.findAll(pageable); - Page authorDtos = authors.map(this::convertToDto); + Page authorDtos = authors.map(this::convertToSummaryDto); return ResponseEntity.ok(authorDtos); } @@ -255,14 +253,14 @@ public class AuthorController { } @GetMapping("/search") - public ResponseEntity> searchAuthors( + public ResponseEntity> searchAuthors( @RequestParam String query, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page authors = authorService.searchByName(query, pageable); - Page authorDtos = authors.map(this::convertToDto); + Page authorDtos = authors.map(this::convertToSummaryDto); return ResponseEntity.ok(authorDtos); } @@ -353,10 +351,10 @@ public class AuthorController { } @GetMapping("/top-rated") - public ResponseEntity> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) { + public ResponseEntity> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) { Pageable pageable = PageRequest.of(0, limit); List authors = authorService.findTopRated(pageable); - List authorDtos = authors.stream().map(this::convertToDto).collect(Collectors.toList()); + List authorDtos = authors.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(authorDtos); } @@ -422,6 +420,24 @@ public class AuthorController { 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) { AuthorDto dto = new AuthorDto(); dto.setId(searchDto.getId()); diff --git a/backend/src/main/java/com/storycove/controller/CollectionController.java b/backend/src/main/java/com/storycove/controller/CollectionController.java new file mode 100644 index 0000000..0bb7e70 --- /dev/null +++ b/backend/src/main/java/com/storycove/controller/CollectionController.java @@ -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> getCollections( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int limit, + @RequestParam(required = false) String search, + @RequestParam(required = false) List 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 results = collectionService.searchCollections(search, tags, archived, page, limit); + + // Convert to lightweight DTOs + SearchResultDto 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 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 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 createCollectionWithImage( + @RequestParam String name, + @RequestParam(required = false) String description, + @RequestParam(required = false) List tags, + @RequestParam(required = false) List 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 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> 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 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> addStoriesToCollection( + @PathVariable UUID id, + @RequestBody AddStoriesRequest request) { + + Map 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> 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> 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> getStoryWithCollectionContext( + @PathVariable UUID id, + @PathVariable UUID storyId) { + + Map result = collectionService.getStoryWithCollectionContext(id, storyId); + return ResponseEntity.ok(result); + } + + /** + * GET /api/collections/{id}/stats - Get collection statistics + */ + @GetMapping("/{id}/stats") + public ResponseEntity> getCollectionStatistics(@PathVariable UUID id) { + Map stats = collectionService.getCollectionStatistics(id); + return ResponseEntity.ok(stats); + } + + /** + * POST /api/collections/{id}/cover - Upload cover image + */ + @PostMapping("/{id}/cover") + public ResponseEntity> 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> 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 tagNames; + private List 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 getTagNames() { return tagNames; } + public void setTagNames(List tagNames) { this.tagNames = tagNames; } + public List getStoryIds() { return storyIds; } + public void setStoryIds(List storyIds) { this.storyIds = storyIds; } + } + + public static class UpdateCollectionRequest { + private String name; + private String description; + private List 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 getTagNames() { return tagNames; } + public void setTagNames(List 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 storyIds; + private Integer position; + + public List getStoryIds() { return storyIds; } + public void setStoryIds(List storyIds) { this.storyIds = storyIds; } + public Integer getPosition() { return position; } + public void setPosition(Integer position) { this.position = position; } + } + + public static class ReorderStoriesRequest { + private List> storyOrders; + + public List> getStoryOrders() { return storyOrders; } + public void setStoryOrders(List> storyOrders) { this.storyOrders = storyOrders; } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 0193b04..107c124 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -1,8 +1,8 @@ package com.storycove.controller; -import com.storycove.dto.StoryDto; -import com.storycove.dto.TagDto; +import com.storycove.dto.*; import com.storycove.entity.Author; +import com.storycove.entity.Collection; import com.storycove.entity.Series; import com.storycove.entity.Story; import com.storycove.entity.Tag; @@ -40,23 +40,26 @@ public class StoryController { private final HtmlSanitizationService sanitizationService; private final ImageService imageService; private final TypesenseService typesenseService; + private final CollectionService collectionService; public StoryController(StoryService storyService, AuthorService authorService, SeriesService seriesService, HtmlSanitizationService sanitizationService, ImageService imageService, + CollectionService collectionService, @Autowired(required = false) TypesenseService typesenseService) { this.storyService = storyService; this.authorService = authorService; this.seriesService = seriesService; this.sanitizationService = sanitizationService; this.imageService = imageService; + this.collectionService = collectionService; this.typesenseService = typesenseService; } @GetMapping - public ResponseEntity> getAllStories( + public ResponseEntity> getAllStories( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "createdAt") String sortBy, @@ -67,7 +70,7 @@ public class StoryController { Pageable pageable = PageRequest.of(page, size, sort); Page stories = storyService.findAll(pageable); - Page storyDtos = stories.map(this::convertToDto); + Page storyDtos = stories.map(this::convertToSummaryDto); return ResponseEntity.ok(storyDtos); } @@ -232,57 +235,73 @@ public class StoryController { } @GetMapping("/author/{authorId}") - public ResponseEntity> getStoriesByAuthor( + public ResponseEntity> getStoriesByAuthor( @PathVariable UUID authorId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page stories = storyService.findByAuthor(authorId, pageable); - Page storyDtos = stories.map(this::convertToDto); + Page storyDtos = stories.map(this::convertToSummaryDto); return ResponseEntity.ok(storyDtos); } @GetMapping("/series/{seriesId}") - public ResponseEntity> getStoriesBySeries(@PathVariable UUID seriesId) { + public ResponseEntity> getStoriesBySeries(@PathVariable UUID seriesId) { List stories = storyService.findBySeriesOrderByVolume(seriesId); - List storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); + List storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(storyDtos); } @GetMapping("/tags/{tagName}") - public ResponseEntity> getStoriesByTag( + public ResponseEntity> getStoriesByTag( @PathVariable String tagName, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page stories = storyService.findByTagNames(List.of(tagName), pageable); - Page storyDtos = stories.map(this::convertToDto); + Page storyDtos = stories.map(this::convertToSummaryDto); return ResponseEntity.ok(storyDtos); } @GetMapping("/recent") - public ResponseEntity> getRecentStories(@RequestParam(defaultValue = "10") int limit) { + public ResponseEntity> getRecentStories(@RequestParam(defaultValue = "10") int limit) { Pageable pageable = PageRequest.of(0, limit, Sort.by("createdAt").descending()); List stories = storyService.findRecentlyAddedLimited(pageable); - List storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); + List storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(storyDtos); } @GetMapping("/top-rated") - public ResponseEntity> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) { + public ResponseEntity> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) { Pageable pageable = PageRequest.of(0, limit); List stories = storyService.findTopRatedStoriesLimited(pageable); - List storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); + List storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(storyDtos); } + @GetMapping("/{id}/collections") + public ResponseEntity> getStoryCollections(@PathVariable UUID id) { + List collections = collectionService.getCollectionsForStory(id); + List collectionDtos = collections.stream() + .map(this::convertToCollectionDto) + .collect(Collectors.toList()); + + return ResponseEntity.ok(collectionDtos); + } + + @PostMapping("/batch/add-to-collection") + public ResponseEntity> 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) { // First try to find existing author by name try { @@ -392,6 +411,38 @@ public class StoryController { 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) { TagDto tagDto = new TagDto(); tagDto.setId(tag.getId()); @@ -401,6 +452,27 @@ public class StoryController { 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 public static class CreateStoryRequest { private String title; @@ -481,4 +553,17 @@ public class StoryController { public Integer getRating() { return rating; } public void setRating(Integer rating) { this.rating = rating; } } + + public static class BatchAddToCollectionRequest { + private List storyIds; + private UUID collectionId; + private String newCollectionName; + + public List getStoryIds() { return storyIds; } + public void setStoryIds(List 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; } + } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java b/backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java new file mode 100644 index 0000000..a2affe1 --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java @@ -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 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 getUrls() { + return urls; + } + + public void setUrls(List 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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/CollectionDto.java b/backend/src/main/java/com/storycove/dto/CollectionDto.java new file mode 100644 index 0000000..290305d --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/CollectionDto.java @@ -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 tags; + private List 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 getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public List getCollectionStories() { + return collectionStories; + } + + public void setCollectionStories(List 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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/CollectionStoryDto.java b/backend/src/main/java/com/storycove/dto/CollectionStoryDto.java new file mode 100644 index 0000000..ae735aa --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/CollectionStoryDto.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/StorySummaryDto.java b/backend/src/main/java/com/storycove/dto/StorySummaryDto.java new file mode 100644 index 0000000..3ab012a --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/StorySummaryDto.java @@ -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 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 getTags() { + return tags; + } + + public void setTags(List 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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/Author.java b/backend/src/main/java/com/storycove/entity/Author.java index 55d4c8a..c820244 100644 --- a/backend/src/main/java/com/storycove/entity/Author.java +++ b/backend/src/main/java/com/storycove/entity/Author.java @@ -5,6 +5,7 @@ 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; @@ -40,6 +41,7 @@ public class Author { private List urls = new ArrayList<>(); @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JsonManagedReference("author-stories") private List stories = new ArrayList<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/entity/Collection.java b/backend/src/main/java/com/storycove/entity/Collection.java new file mode 100644 index 0000000..efd4251 --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/Collection.java @@ -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 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 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 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 getCollectionStories() { + return collectionStories; + } + + public void setCollectionStories(List collectionStories) { + this.collectionStories = collectionStories; + } + + public Set getTags() { + return tags; + } + + public void setTags(Set 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 + + '}'; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/CollectionStory.java b/backend/src/main/java/com/storycove/entity/CollectionStory.java new file mode 100644 index 0000000..5665fd7 --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/CollectionStory.java @@ -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 + + '}'; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/CollectionStoryId.java b/backend/src/main/java/com/storycove/entity/CollectionStoryId.java new file mode 100644 index 0000000..80620dc --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/CollectionStoryId.java @@ -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 + + '}'; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/Series.java b/backend/src/main/java/com/storycove/entity/Series.java index a2a46b5..6b2e037 100644 --- a/backend/src/main/java/com/storycove/entity/Series.java +++ b/backend/src/main/java/com/storycove/entity/Series.java @@ -4,6 +4,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; +import com.fasterxml.jackson.annotation.JsonManagedReference; import java.time.LocalDateTime; import java.util.ArrayList; @@ -29,6 +30,7 @@ public class Series { @OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OrderBy("volume ASC") + @JsonManagedReference("series-stories") private List stories = new ArrayList<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/entity/Story.java b/backend/src/main/java/com/storycove/entity/Story.java index 706c6be..ffb5d71 100644 --- a/backend/src/main/java/com/storycove/entity/Story.java +++ b/backend/src/main/java/com/storycove/entity/Story.java @@ -6,6 +6,8 @@ import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import org.jsoup.Jsoup; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonBackReference; import java.time.LocalDateTime; import java.util.HashSet; @@ -55,10 +57,12 @@ public class Story { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author_id") + @JsonBackReference("author-stories") private Author author; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") + @JsonBackReference("series-stories") private Series series; @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @@ -67,6 +71,7 @@ public class Story { joinColumns = @JoinColumn(name = "story_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) + @JsonManagedReference("story-tags") private Set tags = new HashSet<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/entity/Tag.java b/backend/src/main/java/com/storycove/entity/Tag.java index a5e61e5..4f5867e 100644 --- a/backend/src/main/java/com/storycove/entity/Tag.java +++ b/backend/src/main/java/com/storycove/entity/Tag.java @@ -4,6 +4,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; +import com.fasterxml.jackson.annotation.JsonBackReference; import java.time.LocalDateTime; import java.util.HashSet; @@ -25,6 +26,7 @@ public class Tag { @ManyToMany(mappedBy = "tags") + @JsonBackReference("story-tags") private Set stories = new HashSet<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/repository/CollectionRepository.java b/backend/src/main/java/com/storycove/repository/CollectionRepository.java new file mode 100644 index 0000000..30ffa38 --- /dev/null +++ b/backend/src/main/java/com/storycove/repository/CollectionRepository.java @@ -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 { + + /** + * 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 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 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 findAllActiveCollections(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java b/backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java new file mode 100644 index 0000000..382292c --- /dev/null +++ b/backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java @@ -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 { + + /** + * 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 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 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 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 findNextStory(@Param("collectionId") UUID collectionId, @Param("currentStoryId") UUID currentStoryId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/CollectionSearchResult.java b/backend/src/main/java/com/storycove/service/CollectionSearchResult.java new file mode 100644 index 0000000..a9a1eb9 --- /dev/null +++ b/backend/src/main/java/com/storycove/service/CollectionSearchResult.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/CollectionService.java b/backend/src/main/java/com/storycove/service/CollectionService.java new file mode 100644 index 0000000..6c408cd --- /dev/null +++ b/backend/src/main/java/com/storycove/service/CollectionService.java @@ -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 searchCollections(String query, List 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 tagNames, List initialStoryIds) { + Collection collection = new Collection(name, description); + + // Add tags if provided + if (tagNames != null && !tagNames.isEmpty()) { + Set 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 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 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 addStoriesToCollection(UUID collectionId, List storyIds, Integer startPosition) { + Collection collection = findByIdBasic(collectionId); + + // Validate stories exist + List 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> 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 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 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 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 previousStories = collectionStoryRepository.findPreviousStory(collectionId, storyId); + List 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 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 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 getCollectionStatistics(UUID collectionId) { + Collection collection = findById(collectionId); + + List 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 tagFrequency = collectionStories.stream() + .flatMap(cs -> cs.getStory().getTags().stream()) + .collect(Collectors.groupingBy(Tag::getName, Collectors.counting())); + + // Author distribution + List> authorDistribution = collectionStories.stream() + .filter(cs -> cs.getStory().getAuthor() != null) + .collect(Collectors.groupingBy(cs -> cs.getStory().getAuthor().getName(), Collectors.counting())) + .entrySet().stream() + .map(entry -> Map.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 findOrCreateTags(List tagNames) { + Set 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 getCollectionsForStory(UUID storyId) { + List collectionStories = collectionStoryRepository.findByStoryId(storyId); + return collectionStories.stream() + .map(CollectionStory::getCollection) + .collect(Collectors.toList()); + } + + /** + * Get all collections for indexing (used by TypesenseService) + */ + public List findAllForIndexing() { + return collectionRepository.findAllActiveCollections(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/TypesenseService.java b/backend/src/main/java/com/storycove/service/TypesenseService.java index 2c4079c..e5e99d5 100644 --- a/backend/src/main/java/com/storycove/service/TypesenseService.java +++ b/backend/src/main/java/com/storycove/service/TypesenseService.java @@ -4,7 +4,10 @@ import com.storycove.dto.AuthorSearchDto; import com.storycove.dto.SearchResultDto; import com.storycove.dto.StorySearchDto; import com.storycove.entity.Author; +import com.storycove.entity.Collection; +import com.storycove.entity.CollectionStory; import com.storycove.entity.Story; +import com.storycove.repository.CollectionStoryRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +17,7 @@ import org.typesense.api.Client; import org.typesense.model.*; import jakarta.annotation.PostConstruct; +import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -24,12 +28,16 @@ public class TypesenseService { private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class); private static final String STORIES_COLLECTION = "stories"; private static final String AUTHORS_COLLECTION = "authors"; + private static final String COLLECTIONS_COLLECTION = "collections"; private final Client typesenseClient; + private final CollectionStoryRepository collectionStoryRepository; @Autowired - public TypesenseService(Client typesenseClient) { + public TypesenseService(Client typesenseClient, + @Autowired(required = false) CollectionStoryRepository collectionStoryRepository) { this.typesenseClient = typesenseClient; + this.collectionStoryRepository = collectionStoryRepository; } @PostConstruct @@ -37,6 +45,7 @@ public class TypesenseService { try { createStoriesCollectionIfNotExists(); createAuthorsCollectionIfNotExists(); + createCollectionsCollectionIfNotExists(); } catch (Exception e) { logger.error("Failed to initialize Typesense collections", e); } @@ -936,4 +945,287 @@ public class TypesenseService { 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 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 searchCollections(String query, List 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 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 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 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 collections) { + if (collections == null || collections.isEmpty()) { + return; + } + + try { + List> documents = collections.stream() + .map(this::createCollectionDocument) + .collect(Collectors.toList()); + + for (Map 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 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 createCollectionDocument(Collection collection) { + Map 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 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 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 convertCollectionSearchResult(SearchResult searchResult) { + List collections = new ArrayList<>(); + + if (searchResult.getHits() != null) { + for (SearchResultHit hit : searchResult.getHits()) { + try { + Map 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; + } } \ No newline at end of file diff --git a/frontend/src/app/collections/[id]/edit/page.tsx b/frontend/src/app/collections/[id]/edit/page.tsx new file mode 100644 index 0000000..3c9312f --- /dev/null +++ b/frontend/src/app/collections/[id]/edit/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( + +
+ +
+
+ ); + } + + if (error || !collection) { + return ( + +
+
+ {error || 'Collection not found'} +
+ +
+
+ ); + } + + 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 ( + +
+
+

Edit Collection

+

+ Update your collection details and organization. +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/[id]/page.tsx b/frontend/src/app/collections/[id]/page.tsx new file mode 100644 index 0000000..be42e37 --- /dev/null +++ b/frontend/src/app/collections/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+ +
+
+ ); + } + + if (error || !collection) { + return ( + +
+
+ {error || 'Collection not found'} +
+ +
+
+ ); + } + + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/[id]/read/[storyId]/page.tsx b/frontend/src/app/collections/[id]/read/[storyId]/page.tsx new file mode 100644 index 0000000..15f5c12 --- /dev/null +++ b/frontend/src/app/collections/[id]/read/[storyId]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+ +
+
+ ); + } + + if (error || !data) { + return ( + +
+
+ {error || 'Story not found'} +
+ +
+
+ ); + } + + return ( + + router.push(`/collections/${collectionId}`)} + /> + + ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/new/page.tsx b/frontend/src/app/collections/new/page.tsx new file mode 100644 index 0000000..a0bf08a --- /dev/null +++ b/frontend/src/app/collections/new/page.tsx @@ -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(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 ( + +
+
+

Create New Collection

+

+ Organize your stories into a curated collection for better reading experience. +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/page.tsx b/frontend/src/app/collections/page.tsx new file mode 100644 index 0000000..7f972d6 --- /dev/null +++ b/frontend/src/app/collections/page.tsx @@ -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([]); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); + const [viewMode, setViewMode] = useState('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) => { + 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 ( + +
+ +
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+

Collections

+

+ {totalCollections} {totalCollections === 1 ? 'collection' : 'collections'} + {searchQuery || selectedTags.length > 0 || showArchived ? ` found` : ` total`} +

+
+ + +
+ + {/* Search and Filters */} +
+ {/* Search Bar */} +
+
+ +
+ + {/* View Mode Toggle */} +
+ + +
+
+ + {/* Filters and Controls */} +
+ {/* Page Size Selector */} +
+ + +
+ + {/* Archive Toggle */} + + + {/* Clear Filters */} + {(searchQuery || selectedTags.length > 0 || showArchived) && ( + + )} +
+ + {/* Tag Filter */} + +
+ + {/* Collections Display */} + + + {/* Pagination */} + {totalPages > 1 && ( +
+ + +
+ Page + { + 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" + /> + of {totalPages} +
+ + +
+ )} + + {/* Loading Overlay */} + {loading && collections.length > 0 && ( +
+
+ +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/library/page.tsx b/frontend/src/app/library/page.tsx index 05b6c5c..2d015d1 100644 --- a/frontend/src/app/library/page.tsx +++ b/frontend/src/app/library/page.tsx @@ -6,7 +6,7 @@ import { Story, Tag } from '../../types/api'; import AppLayout from '../../components/layout/AppLayout'; import { Input } from '../../components/ui/Input'; 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 LoadingSpinner from '../../components/ui/LoadingSpinner'; @@ -242,20 +242,12 @@ export default function LibraryPage() { )} ) : ( -
- {stories.map((story) => ( - - ))} -
+ )} {/* Pagination */} diff --git a/frontend/src/app/stories/[id]/detail/page.tsx b/frontend/src/app/stories/[id]/detail/page.tsx index 0366073..337d637 100644 --- a/frontend/src/app/stories/[id]/detail/page.tsx +++ b/frontend/src/app/stories/[id]/detail/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; 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 Button from '../../../../components/ui/Button'; import LoadingSpinner from '../../../../components/ui/LoadingSpinner'; @@ -17,6 +17,7 @@ export default function StoryDetailPage() { const [story, setStory] = useState(null); const [seriesStories, setSeriesStories] = useState([]); + const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(false); @@ -32,6 +33,10 @@ export default function StoryDetailPage() { const seriesData = await seriesApi.getSeriesStories(storyData.seriesId); setSeriesStories(seriesData); } + + // Load collections that contain this story + const collectionsData = await storyApi.getStoryCollections(storyId); + setCollections(collectionsData); } catch (error) { console.error('Failed to load story data:', error); router.push('/library'); @@ -250,6 +255,57 @@ export default function StoryDetailPage() { )} + {/* Collections */} + {collections.length > 0 && ( +
+

+ Part of Collections ({collections.length}) +

+
+ {collections.map((collection) => ( + +
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+ + {collection.storyCount} + +
+ )} +
+

+ {collection.name} +

+

+ {collection.storyCount} {collection.storyCount === 1 ? 'story' : 'stories'} + {collection.estimatedReadingTime && ( + • ~{Math.ceil(collection.estimatedReadingTime / 60)}h reading + )} +

+
+ {collection.rating && ( +
+ + {collection.rating} +
+ )} +
+ + ))} +
+
+ )} + {/* Summary */} {story.summary && (
diff --git a/frontend/src/components/collections/AddToCollectionModal.tsx b/frontend/src/components/collections/AddToCollectionModal.tsx new file mode 100644 index 0000000..1d06904 --- /dev/null +++ b/frontend/src/components/collections/AddToCollectionModal.tsx @@ -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([]); + const [selectedStoryIds, setSelectedStoryIds] = useState([]); + 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 ( +
+
+ {/* Header */} +
+

+ Add Stories to "{collection.name}" +

+ +
+ + {/* Search */} +
+ setSearchQuery(e.target.value)} + placeholder="Search stories to add..." + className="w-full" + /> +
+ + {/* Stories List */} +
+ {loading ? ( +
+ +
+ ) : availableStories.length === 0 ? ( +
+ {searchQuery ? 'No stories found matching your search.' : 'All stories are already in this collection.'} +
+ ) : ( +
+ {availableStories.map((story) => { + const isSelected = selectedStoryIds.includes(story.id); + return ( +
toggleStorySelection(story.id)} + > +
+ toggleStorySelection(story.id)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+

+ {story.title} +

+

+ by {story.authorName} +

+
+ {story.wordCount?.toLocaleString()} words + {story.rating && ( + + ★ {story.rating} + + )} +
+
+
+
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ {selectedStoryIds.length} stories selected +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/collections/CollectionCard.tsx b/frontend/src/components/collections/CollectionCard.tsx new file mode 100644 index 0000000..28fcdab --- /dev/null +++ b/frontend/src/components/collections/CollectionCard.tsx @@ -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 ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ★ + + ))} +
+ ); + }; + + if (viewMode === 'grid') { + return ( + +
+ {/* Cover Image or Placeholder */} +
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+
+
+ {collection.storyCount} +
+
+ {collection.storyCount === 1 ? 'story' : 'stories'} +
+
+
+ )} + + {collection.isArchived && ( +
+ Archived +
+ )} +
+ + {/* Collection Info */} +
+

+ {collection.name} +

+ + {collection.description && ( +

+ {collection.description} +

+ )} + +
+ {collection.storyCount} stories + {collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'} +
+ + {collection.rating && ( +
+ {renderRatingStars(collection.rating)} +
+ )} + + {/* Tags */} + {collection.tags && collection.tags.length > 0 && ( +
+ {collection.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} + {collection.tags.length > 3 && ( + + +{collection.tags.length - 3} more + + )} +
+ )} +
+
+ + ); + } + + // List view + return ( + +
+
+ {/* Cover Image */} +
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+
+
+ {collection.storyCount} +
+
+
+ )} +
+ + {/* Collection Details */} +
+
+
+

+ {collection.name} + {collection.isArchived && ( + + Archived + + )} +

+ + {collection.description && ( +

+ {collection.description} +

+ )} + +
+ {collection.storyCount} stories + {collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'} reading + {collection.averageStoryRating && collection.averageStoryRating > 0 && ( + ★ {collection.averageStoryRating.toFixed(1)} avg + )} +
+ + {/* Tags */} + {collection.tags && collection.tags.length > 0 && ( +
+ {collection.tags.slice(0, 5).map((tag) => ( + + {tag.name} + + ))} + {collection.tags.length > 5 && ( + + +{collection.tags.length - 5} more + + )} +
+ )} +
+ + {collection.rating && ( +
+ {renderRatingStars(collection.rating)} +
+ )} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/collections/CollectionDetailView.tsx b/frontend/src/components/collections/CollectionDetailView.tsx new file mode 100644 index 0000000..0455cd6 --- /dev/null +++ b/frontend/src/components/collections/CollectionDetailView.tsx @@ -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(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 ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ★ + + ))} +
+ ); + }; + + 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 ( +
+ {/* Header Section */} +
+
+ {/* Cover Image */} +
+
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+
+
+ {collection.storyCount} +
+
+ {collection.storyCount === 1 ? 'story' : 'stories'} +
+
+
+ )} +
+
+ + {/* Collection Info */} +
+
+
+ {isEditing ? ( +
+ 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" + /> +