feature/collections #1

Merged
shardegger merged 3 commits from feature/collections into main 2025-07-25 14:22:57 +02:00
43 changed files with 6040 additions and 45 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
package com.storycove.controller; package com.storycove.controller;
import com.storycove.dto.StoryDto; import com.storycove.dto.*;
import com.storycove.dto.TagDto;
import com.storycove.entity.Author; import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.Series; import com.storycove.entity.Series;
import com.storycove.entity.Story; import com.storycove.entity.Story;
import com.storycove.entity.Tag; import com.storycove.entity.Tag;
@@ -40,23 +40,26 @@ public class StoryController {
private final HtmlSanitizationService sanitizationService; private final HtmlSanitizationService sanitizationService;
private final ImageService imageService; private final ImageService imageService;
private final TypesenseService typesenseService; private final TypesenseService typesenseService;
private final CollectionService collectionService;
public StoryController(StoryService storyService, public StoryController(StoryService storyService,
AuthorService authorService, AuthorService authorService,
SeriesService seriesService, SeriesService seriesService,
HtmlSanitizationService sanitizationService, HtmlSanitizationService sanitizationService,
ImageService imageService, ImageService imageService,
CollectionService collectionService,
@Autowired(required = false) TypesenseService typesenseService) { @Autowired(required = false) TypesenseService typesenseService) {
this.storyService = storyService; this.storyService = storyService;
this.authorService = authorService; this.authorService = authorService;
this.seriesService = seriesService; this.seriesService = seriesService;
this.sanitizationService = sanitizationService; this.sanitizationService = sanitizationService;
this.imageService = imageService; this.imageService = imageService;
this.collectionService = collectionService;
this.typesenseService = typesenseService; this.typesenseService = typesenseService;
} }
@GetMapping @GetMapping
public ResponseEntity<Page<StoryDto>> getAllStories( public ResponseEntity<Page<StorySummaryDto>> getAllStories(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy, @RequestParam(defaultValue = "createdAt") String sortBy,
@@ -67,7 +70,7 @@ public class StoryController {
Pageable pageable = PageRequest.of(page, size, sort); Pageable pageable = PageRequest.of(page, size, sort);
Page<Story> stories = storyService.findAll(pageable); Page<Story> stories = storyService.findAll(pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto); Page<StorySummaryDto> storyDtos = stories.map(this::convertToSummaryDto);
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@@ -232,57 +235,73 @@ public class StoryController {
} }
@GetMapping("/author/{authorId}") @GetMapping("/author/{authorId}")
public ResponseEntity<Page<StoryDto>> getStoriesByAuthor( public ResponseEntity<Page<StorySummaryDto>> getStoriesByAuthor(
@PathVariable UUID authorId, @PathVariable UUID authorId,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size); Pageable pageable = PageRequest.of(page, size);
Page<Story> stories = storyService.findByAuthor(authorId, pageable); Page<Story> stories = storyService.findByAuthor(authorId, pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto); Page<StorySummaryDto> storyDtos = stories.map(this::convertToSummaryDto);
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/series/{seriesId}") @GetMapping("/series/{seriesId}")
public ResponseEntity<List<StoryDto>> getStoriesBySeries(@PathVariable UUID seriesId) { public ResponseEntity<List<StorySummaryDto>> getStoriesBySeries(@PathVariable UUID seriesId) {
List<Story> stories = storyService.findBySeriesOrderByVolume(seriesId); List<Story> stories = storyService.findBySeriesOrderByVolume(seriesId);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); List<StorySummaryDto> storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/tags/{tagName}") @GetMapping("/tags/{tagName}")
public ResponseEntity<Page<StoryDto>> getStoriesByTag( public ResponseEntity<Page<StorySummaryDto>> getStoriesByTag(
@PathVariable String tagName, @PathVariable String tagName,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size); Pageable pageable = PageRequest.of(page, size);
Page<Story> stories = storyService.findByTagNames(List.of(tagName), pageable); Page<Story> stories = storyService.findByTagNames(List.of(tagName), pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto); Page<StorySummaryDto> storyDtos = stories.map(this::convertToSummaryDto);
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/recent") @GetMapping("/recent")
public ResponseEntity<List<StoryDto>> getRecentStories(@RequestParam(defaultValue = "10") int limit) { public ResponseEntity<List<StorySummaryDto>> getRecentStories(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit, Sort.by("createdAt").descending()); Pageable pageable = PageRequest.of(0, limit, Sort.by("createdAt").descending());
List<Story> stories = storyService.findRecentlyAddedLimited(pageable); List<Story> stories = storyService.findRecentlyAddedLimited(pageable);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); List<StorySummaryDto> storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/top-rated") @GetMapping("/top-rated")
public ResponseEntity<List<StoryDto>> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) { public ResponseEntity<List<StorySummaryDto>> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit); Pageable pageable = PageRequest.of(0, limit);
List<Story> stories = storyService.findTopRatedStoriesLimited(pageable); List<Story> stories = storyService.findTopRatedStoriesLimited(pageable);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); List<StorySummaryDto> storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos); return ResponseEntity.ok(storyDtos);
} }
@GetMapping("/{id}/collections")
public ResponseEntity<List<CollectionDto>> getStoryCollections(@PathVariable UUID id) {
List<Collection> collections = collectionService.getCollectionsForStory(id);
List<CollectionDto> collectionDtos = collections.stream()
.map(this::convertToCollectionDto)
.collect(Collectors.toList());
return ResponseEntity.ok(collectionDtos);
}
@PostMapping("/batch/add-to-collection")
public ResponseEntity<Map<String, Object>> addStoriesToCollection(@RequestBody BatchAddToCollectionRequest request) {
// This endpoint will be implemented once we have the complete collection service
return ResponseEntity.ok(Map.of("message", "Batch add to collection endpoint - to be implemented"));
}
private Author findOrCreateAuthor(String authorName) { private Author findOrCreateAuthor(String authorName) {
// First try to find existing author by name // First try to find existing author by name
try { try {
@@ -392,6 +411,38 @@ public class StoryController {
return dto; return dto;
} }
private StorySummaryDto convertToSummaryDto(Story story) {
StorySummaryDto dto = new StorySummaryDto();
dto.setId(story.getId());
dto.setTitle(story.getTitle());
dto.setSummary(story.getSummary());
dto.setDescription(story.getDescription());
dto.setSourceUrl(story.getSourceUrl());
dto.setCoverPath(story.getCoverPath());
dto.setWordCount(story.getWordCount());
dto.setRating(story.getRating());
dto.setVolume(story.getVolume());
dto.setCreatedAt(story.getCreatedAt());
dto.setUpdatedAt(story.getUpdatedAt());
dto.setPartOfSeries(story.isPartOfSeries());
if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId());
dto.setAuthorName(story.getAuthor().getName());
}
if (story.getSeries() != null) {
dto.setSeriesId(story.getSeries().getId());
dto.setSeriesName(story.getSeries().getName());
}
dto.setTags(story.getTags().stream()
.map(this::convertTagToDto)
.collect(Collectors.toList()));
return dto;
}
private TagDto convertTagToDto(Tag tag) { private TagDto convertTagToDto(Tag tag) {
TagDto tagDto = new TagDto(); TagDto tagDto = new TagDto();
tagDto.setId(tag.getId()); tagDto.setId(tag.getId());
@@ -401,6 +452,27 @@ public class StoryController {
return tagDto; return tagDto;
} }
private CollectionDto convertToCollectionDto(Collection collection) {
CollectionDto dto = new CollectionDto();
dto.setId(collection.getId());
dto.setName(collection.getName());
dto.setDescription(collection.getDescription());
dto.setRating(collection.getRating());
dto.setCoverImagePath(collection.getCoverImagePath());
dto.setIsArchived(collection.getIsArchived());
dto.setCreatedAt(collection.getCreatedAt());
dto.setUpdatedAt(collection.getUpdatedAt());
// For story collections endpoint, we don't need to map the stories themselves
// to avoid circular references and keep it lightweight
dto.setStoryCount(collection.getStoryCount());
dto.setTotalWordCount(collection.getTotalWordCount());
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
dto.setAverageStoryRating(collection.getAverageStoryRating());
return dto;
}
// Request DTOs // Request DTOs
public static class CreateStoryRequest { public static class CreateStoryRequest {
private String title; private String title;
@@ -481,4 +553,17 @@ public class StoryController {
public Integer getRating() { return rating; } public Integer getRating() { return rating; }
public void setRating(Integer rating) { this.rating = rating; } public void setRating(Integer rating) { this.rating = rating; }
} }
public static class BatchAddToCollectionRequest {
private List<UUID> storyIds;
private UUID collectionId;
private String newCollectionName;
public List<UUID> getStoryIds() { return storyIds; }
public void setStoryIds(List<UUID> storyIds) { this.storyIds = storyIds; }
public UUID getCollectionId() { return collectionId; }
public void setCollectionId(UUID collectionId) { this.collectionId = collectionId; }
public String getNewCollectionName() { return newCollectionName; }
public void setNewCollectionName(String newCollectionName) { this.newCollectionName = newCollectionName; }
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

BIN
pinch-and-twist.epub Normal file

Binary file not shown.

View File

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