diff --git a/0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg b/0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg new file mode 100644 index 0000000..7e395d8 Binary files /dev/null and b/0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg differ diff --git a/backend/src/main/java/com/storycove/controller/AuthorController.java b/backend/src/main/java/com/storycove/controller/AuthorController.java index 29c63d2..021ad57 100644 --- a/backend/src/main/java/com/storycove/controller/AuthorController.java +++ b/backend/src/main/java/com/storycove/controller/AuthorController.java @@ -1,8 +1,6 @@ package com.storycove.controller; -import com.storycove.dto.AuthorDto; -import com.storycove.dto.AuthorSearchDto; -import com.storycove.dto.SearchResultDto; +import com.storycove.dto.*; import com.storycove.entity.Author; import com.storycove.service.AuthorService; import com.storycove.service.ImageService; @@ -43,7 +41,7 @@ public class AuthorController { } @GetMapping - public ResponseEntity> 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" + /> +